sl-webui d1aea87bd7 feat: SaltyFace web app UI for Chromium kiosk (Issue #370)
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>
2026-03-04 08:44:37 -05:00

468 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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');
}