Animated robot expression interface as lightweight web application: **Architecture:** - HTML5 Canvas rendering engine - Node.js HTTP server (localhost:3000) - ROSLIB WebSocket bridge for ROS2 topics - Fullscreen responsive design (1024×600) **Features:** - 8 emotional states (happy, alert, confused, sleeping, excited, emergency, listening, talking) - Real-time ROS2 subscriptions: - /saltybot/state (emotion triggers) - /saltybot/battery (status display) - /saltybot/target_track (EXCITED emotion) - /saltybot/obstacles (ALERT emotion) - /social/speech/is_speaking (TALKING emotion) - /social/speech/is_listening (LISTENING emotion) - Tap-to-toggle status overlay - 60fps Canvas animation on Wayland - ~80MB total memory (Node.js + browser) **Files:** - public/index.html — Main page (1024×600 fullscreen) - public/salty-face.js — Canvas rendering + ROS2 integration - server.js — Node.js HTTP server with CORS support - systemd/salty-face-server.service — Auto-start systemd service - docs/SALTY_FACE_WEB_APP.md — Complete setup & API documentation **Integration:** - Runs in Chromium kiosk (Issue #374) - Depends on rosbridge_server for WebSocket bridge - Serves on localhost:3000 (configurable) **Next:** Issue #371 (Accessibility enhancements) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
468 lines
13 KiB
JavaScript
468 lines
13 KiB
JavaScript
/**
|
||
* SaltyFace Web App — Animated robot expression UI for Chromium kiosk
|
||
*
|
||
* Runs fullscreen in Cage Wayland compositor on MageDok 7" display
|
||
* Replaces React desktop component with lightweight web app
|
||
*
|
||
* Features:
|
||
* - 8 emotional states (happy, alert, confused, sleeping, excited, emergency, listening, talking)
|
||
* - Canvas-based rendering (30fps target)
|
||
* - ROS2 bridge integration via WebSocket (rosbridge)
|
||
* - Touch-responsive status overlay
|
||
* - 1024×600 fullscreen on MageDok display
|
||
*/
|
||
|
||
// Emotion configuration
|
||
const EMOTIONS = {
|
||
HAPPY: 'happy',
|
||
ALERT: 'alert',
|
||
CONFUSED: 'confused',
|
||
SLEEPING: 'sleeping',
|
||
EXCITED: 'excited',
|
||
EMERGENCY: 'emergency',
|
||
LISTENING: 'listening',
|
||
TALKING: 'talking',
|
||
};
|
||
|
||
const EMOTION_CONFIG = {
|
||
[EMOTIONS.HAPPY]: {
|
||
eyeScale: 1.0,
|
||
pupilPos: { x: 0, y: 0 },
|
||
blink: true,
|
||
blinkRate: 3000,
|
||
color: '#10b981',
|
||
},
|
||
[EMOTIONS.ALERT]: {
|
||
eyeScale: 1.3,
|
||
pupilPos: { x: 0, y: -3 },
|
||
blink: false,
|
||
color: '#ef4444',
|
||
},
|
||
[EMOTIONS.CONFUSED]: {
|
||
eyeScale: 1.1,
|
||
pupilPos: { x: 0, y: 0 },
|
||
blink: true,
|
||
blinkRate: 1500,
|
||
eyeWander: true,
|
||
color: '#f59e0b',
|
||
},
|
||
[EMOTIONS.SLEEPING]: {
|
||
eyeScale: 0.3,
|
||
pupilPos: { x: 0, y: 0 },
|
||
blink: false,
|
||
isClosed: true,
|
||
color: '#6b7280',
|
||
},
|
||
[EMOTIONS.EXCITED]: {
|
||
eyeScale: 1.2,
|
||
pupilPos: { x: 0, y: 0 },
|
||
blink: true,
|
||
blinkRate: 800,
|
||
bounce: true,
|
||
color: '#22c55e',
|
||
},
|
||
[EMOTIONS.EMERGENCY]: {
|
||
eyeScale: 1.4,
|
||
pupilPos: { x: 0, y: -4 },
|
||
blink: false,
|
||
color: '#dc2626',
|
||
flash: true,
|
||
},
|
||
[EMOTIONS.LISTENING]: {
|
||
eyeScale: 1.0,
|
||
pupilPos: { x: 0, y: -2 },
|
||
blink: true,
|
||
blinkRate: 2000,
|
||
color: '#0ea5e9',
|
||
},
|
||
[EMOTIONS.TALKING]: {
|
||
eyeScale: 1.0,
|
||
pupilPos: { x: 0, y: 0 },
|
||
blink: true,
|
||
blinkRate: 2500,
|
||
color: '#06b6d4',
|
||
},
|
||
};
|
||
|
||
// Mouth animation frames
|
||
const MOUTH_FRAMES = [
|
||
{ open: 0.0, shape: 'closed' },
|
||
{ open: 0.3, shape: 'smile-closed' },
|
||
{ open: 0.5, shape: 'smile-open' },
|
||
{ open: 0.7, shape: 'oh' },
|
||
{ open: 0.9, shape: 'ah' },
|
||
{ open: 0.7, shape: 'ee' },
|
||
];
|
||
|
||
// ROS2 bridge WebSocket
|
||
let ros = null;
|
||
const WS_URL = 'ws://localhost:9090';
|
||
let rosConnected = false;
|
||
|
||
// Animation state
|
||
let state = {
|
||
emotion: EMOTIONS.HAPPY,
|
||
isTalking: false,
|
||
isListening: false,
|
||
audioLevel: 0,
|
||
showOverlay: true,
|
||
botState: {
|
||
battery: 85,
|
||
state: 'IDLE',
|
||
distance: 0.0,
|
||
speed: 0.0,
|
||
health: 90,
|
||
hasTarget: false,
|
||
obstacles: 0,
|
||
},
|
||
frameCount: 0,
|
||
isBlinking: false,
|
||
mouthFrame: 0,
|
||
eyeWanderOffset: { x: 0, y: 0 },
|
||
};
|
||
|
||
// Drawing helpers
|
||
function drawEye(ctx, x, y, radius, config, isBlinking) {
|
||
ctx.fillStyle = '#1f2937';
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
if (isBlinking) {
|
||
ctx.strokeStyle = config.color;
|
||
ctx.lineWidth = 3;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x - radius * 0.7, y);
|
||
ctx.lineTo(x + radius * 0.7, y);
|
||
ctx.stroke();
|
||
} else {
|
||
const pupilRadius = radius * (config.eyeScale / 2);
|
||
ctx.fillStyle = config.color;
|
||
ctx.beginPath();
|
||
ctx.arc(x + config.pupilPos.x, y + config.pupilPos.y, pupilRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// Highlight
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
|
||
ctx.beginPath();
|
||
ctx.arc(x + config.pupilPos.x + pupilRadius * 0.4, y + config.pupilPos.y - pupilRadius * 0.4, pupilRadius * 0.3, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
function drawMouth(ctx, x, y, width, frame) {
|
||
ctx.strokeStyle = '#f59e0b';
|
||
ctx.lineWidth = 3;
|
||
ctx.lineCap = 'round';
|
||
|
||
if (frame.shape === 'closed') {
|
||
ctx.beginPath();
|
||
ctx.moveTo(x - width * 0.4, y);
|
||
ctx.lineTo(x + width * 0.4, y);
|
||
ctx.stroke();
|
||
} else if (frame.shape === 'smile-open' || frame.shape === 'smile-closed') {
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, width * 0.5, 0, Math.PI, false);
|
||
ctx.stroke();
|
||
} else if (frame.shape === 'oh') {
|
||
ctx.fillStyle = '#f59e0b';
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, width * 0.35 * frame.open, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
} else if (frame.shape === 'ah') {
|
||
ctx.beginPath();
|
||
ctx.moveTo(x - width * 0.3, y - width * 0.2 * frame.open);
|
||
ctx.lineTo(x + width * 0.3, y - width * 0.2 * frame.open);
|
||
ctx.lineTo(x + width * 0.2, y + width * 0.3 * frame.open);
|
||
ctx.lineTo(x - width * 0.2, y + width * 0.3 * frame.open);
|
||
ctx.closePath();
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
function drawFace(ctx, W, H) {
|
||
const config = EMOTION_CONFIG[state.emotion] || EMOTION_CONFIG[EMOTIONS.HAPPY];
|
||
|
||
// Clear canvas
|
||
ctx.fillStyle = 'rgba(5, 5, 16, 0.95)';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
const centerX = W / 2;
|
||
const centerY = H / 2.2;
|
||
const eyeRadius = 40;
|
||
const eyeSpacing = 80;
|
||
|
||
// Eye wandering (confused state)
|
||
let eyeOffX = 0, eyeOffY = 0;
|
||
if (config.eyeWander) {
|
||
eyeOffX = Math.sin(state.frameCount * 0.02) * 8;
|
||
eyeOffY = Math.cos(state.frameCount * 0.015) * 8;
|
||
}
|
||
|
||
// Bounce (excited state)
|
||
let bounceOffset = 0;
|
||
if (config.bounce) {
|
||
bounceOffset = Math.sin(state.frameCount * 0.08) * 6;
|
||
}
|
||
|
||
// Draw eyes
|
||
const eyeY = centerY + bounceOffset;
|
||
drawEye(ctx, centerX - eyeSpacing, eyeY + eyeOffY, eyeRadius, config, state.isBlinking);
|
||
drawEye(ctx, centerX + eyeSpacing, eyeY + eyeOffY, eyeRadius, config, state.isBlinking);
|
||
|
||
// Draw mouth (if talking)
|
||
if (state.isTalking && !config.isClosed) {
|
||
drawMouth(ctx, centerX, centerY + 80, 50, MOUTH_FRAMES[state.mouthFrame]);
|
||
}
|
||
|
||
// Flash (emergency state)
|
||
if (config.flash && Math.sin(state.frameCount * 0.1) > 0.7) {
|
||
ctx.fillStyle = 'rgba(220, 38, 38, 0.3)';
|
||
ctx.fillRect(0, 0, W, H);
|
||
}
|
||
|
||
state.frameCount++;
|
||
}
|
||
|
||
// ROS2 Bridge functions
|
||
function initRosBridge() {
|
||
console.log('[SaltyFace] Connecting to ROS bridge at', WS_URL);
|
||
|
||
const ROSLIB = window.ROSLIB;
|
||
if (!ROSLIB) {
|
||
console.error('[SaltyFace] ROSLIB not loaded');
|
||
return false;
|
||
}
|
||
|
||
ros = new ROSLIB.Ros({
|
||
url: WS_URL,
|
||
});
|
||
|
||
ros.on('connection', () => {
|
||
console.log('[SaltyFace] Connected to ROS bridge');
|
||
rosConnected = true;
|
||
updateWSStatus(true);
|
||
subscribeToRosTopics();
|
||
});
|
||
|
||
ros.on('error', (error) => {
|
||
console.error('[SaltyFace] ROS error:', error);
|
||
rosConnected = false;
|
||
updateWSStatus(false);
|
||
});
|
||
|
||
ros.on('close', () => {
|
||
console.log('[SaltyFace] ROS connection closed');
|
||
rosConnected = false;
|
||
updateWSStatus(false);
|
||
});
|
||
|
||
return true;
|
||
}
|
||
|
||
function subscribeToRosTopics() {
|
||
if (!ros || !rosConnected) return;
|
||
|
||
// Subscribe to robot state
|
||
const stateTopic = new ROSLIB.Topic({
|
||
ros,
|
||
name: '/saltybot/state',
|
||
messageType: 'std_msgs/String',
|
||
});
|
||
stateTopic.subscribe((msg) => {
|
||
try {
|
||
const data = JSON.parse(msg.data);
|
||
state.botState.state = data.state || 'IDLE';
|
||
|
||
if (data.state === 'EMERGENCY') {
|
||
state.emotion = EMOTIONS.EMERGENCY;
|
||
} else if (data.state === 'TRACKING') {
|
||
state.emotion = EMOTIONS.HAPPY;
|
||
} else if (data.state === 'SEARCHING') {
|
||
state.emotion = EMOTIONS.CONFUSED;
|
||
} else if (data.state === 'IDLE') {
|
||
state.emotion = EMOTIONS.HAPPY;
|
||
}
|
||
} catch (e) {}
|
||
});
|
||
|
||
// Battery topic
|
||
const batteryTopic = new ROSLIB.Topic({
|
||
ros,
|
||
name: '/saltybot/battery',
|
||
messageType: 'std_msgs/Float32',
|
||
});
|
||
batteryTopic.subscribe((msg) => {
|
||
state.botState.battery = Math.round(msg.data);
|
||
updateUI();
|
||
});
|
||
|
||
// Target tracking
|
||
const targetTopic = new ROSLIB.Topic({
|
||
ros,
|
||
name: '/saltybot/target_track',
|
||
messageType: 'geometry_msgs/Pose',
|
||
});
|
||
targetTopic.subscribe((msg) => {
|
||
state.botState.hasTarget = !!msg;
|
||
if (msg) state.emotion = EMOTIONS.EXCITED;
|
||
});
|
||
|
||
// Obstacles
|
||
const obstacleTopic = new ROSLIB.Topic({
|
||
ros,
|
||
name: '/saltybot/obstacles',
|
||
messageType: 'sensor_msgs/LaserScan',
|
||
});
|
||
obstacleTopic.subscribe((msg) => {
|
||
const obstacleCount = msg?.ranges?.filter((r) => r < 0.5).length || 0;
|
||
state.botState.obstacles = obstacleCount;
|
||
if (obstacleCount > 0) state.emotion = EMOTIONS.ALERT;
|
||
});
|
||
|
||
// Speech
|
||
const speakingTopic = new ROSLIB.Topic({
|
||
ros,
|
||
name: '/social/speech/is_speaking',
|
||
messageType: 'std_msgs/Bool',
|
||
});
|
||
speakingTopic.subscribe((msg) => {
|
||
state.isTalking = msg.data || false;
|
||
if (msg.data) state.emotion = EMOTIONS.TALKING;
|
||
});
|
||
|
||
const listeningTopic = new ROSLIB.Topic({
|
||
ros,
|
||
name: '/social/speech/is_listening',
|
||
messageType: 'std_msgs/Bool',
|
||
});
|
||
listeningTopic.subscribe((msg) => {
|
||
state.isListening = msg.data || false;
|
||
if (msg.data) state.emotion = EMOTIONS.LISTENING;
|
||
});
|
||
|
||
// Audio level
|
||
const audioTopic = new ROSLIB.Topic({
|
||
ros,
|
||
name: '/saltybot/audio_level',
|
||
messageType: 'std_msgs/Float32',
|
||
});
|
||
audioTopic.subscribe((msg) => {
|
||
state.audioLevel = msg.data || 0;
|
||
});
|
||
}
|
||
|
||
function updateWSStatus(connected) {
|
||
const dot = document.getElementById('ws-dot');
|
||
const label = document.getElementById('ws-label');
|
||
const container = document.getElementById('ws-status');
|
||
|
||
if (connected) {
|
||
dot.style.backgroundColor = '#10b981';
|
||
label.textContent = 'CONNECTED';
|
||
container.classList.add('connected');
|
||
container.classList.remove('disconnected');
|
||
} else {
|
||
dot.style.backgroundColor = '#ef4444';
|
||
label.textContent = 'DISCONNECTED';
|
||
container.classList.remove('connected');
|
||
container.classList.add('disconnected');
|
||
}
|
||
}
|
||
|
||
function updateUI() {
|
||
document.getElementById('battery').textContent = state.botState.battery;
|
||
document.getElementById('state').textContent = state.botState.state;
|
||
document.getElementById('distance').textContent = state.botState.distance.toFixed(1);
|
||
document.getElementById('speed').textContent = state.botState.speed.toFixed(1) + ' m/s';
|
||
document.getElementById('health').textContent = state.botState.health;
|
||
document.getElementById('emotion').textContent = state.emotion.toUpperCase();
|
||
document.getElementById('talking').textContent = state.isTalking ? 'YES' : 'NO';
|
||
document.getElementById('audio-level').textContent = Math.round(state.audioLevel * 100) + '%';
|
||
|
||
// Health color
|
||
const healthIcon = document.getElementById('health-icon');
|
||
if (state.botState.health > 75) {
|
||
healthIcon.style.color = '#10b981';
|
||
} else if (state.botState.health > 50) {
|
||
healthIcon.style.color = '#f59e0b';
|
||
} else {
|
||
healthIcon.style.color = '#ef4444';
|
||
}
|
||
}
|
||
|
||
// Animation loop
|
||
function animationLoop(canvas, ctx) {
|
||
drawFace(ctx, canvas.width, canvas.height);
|
||
requestAnimationFrame(() => animationLoop(canvas, ctx));
|
||
}
|
||
|
||
// Blinking animation
|
||
function startBlinking() {
|
||
const config = EMOTION_CONFIG[state.emotion] || EMOTION_CONFIG[EMOTIONS.HAPPY];
|
||
if (!config.blink || config.isClosed) return;
|
||
|
||
setInterval(() => {
|
||
state.isBlinking = true;
|
||
setTimeout(() => {
|
||
state.isBlinking = false;
|
||
}, 150);
|
||
}, config.blinkRate);
|
||
}
|
||
|
||
// Mouth animation
|
||
function startMouthAnimation() {
|
||
setInterval(() => {
|
||
if (!state.isTalking) {
|
||
state.mouthFrame = 0;
|
||
return;
|
||
}
|
||
state.mouthFrame = (state.mouthFrame + 1) % MOUTH_FRAMES.length;
|
||
}, 100);
|
||
}
|
||
|
||
// Initialize
|
||
export function initSaltyFace() {
|
||
const canvas = document.getElementById('canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const overlay = document.getElementById('overlay');
|
||
|
||
// Start animation loop
|
||
animationLoop(canvas, ctx);
|
||
|
||
// Start blinking
|
||
startBlinking();
|
||
|
||
// Start mouth animation
|
||
startMouthAnimation();
|
||
|
||
// Tap to toggle overlay
|
||
canvas.addEventListener('click', () => {
|
||
state.showOverlay = !state.showOverlay;
|
||
overlay.classList.toggle('hidden', !state.showOverlay);
|
||
});
|
||
|
||
// Prevent context menu on long press
|
||
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||
|
||
// Initialize ROS bridge
|
||
// Load ROSLIB dynamically
|
||
const script = document.createElement('script');
|
||
script.src = 'https://cdn.jsdelivr.net/npm/roslib@1.3.0/build/roslib.min.js';
|
||
script.onload = () => {
|
||
initRosBridge();
|
||
};
|
||
script.onerror = () => {
|
||
console.warn('[SaltyFace] Failed to load ROSLIB from CDN, trying localhost');
|
||
setTimeout(() => {
|
||
initRosBridge();
|
||
}, 2000);
|
||
};
|
||
document.head.appendChild(script);
|
||
|
||
// Update UI periodically
|
||
setInterval(updateUI, 500);
|
||
|
||
console.log('[SaltyFace] Web app initialized');
|
||
}
|