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>
130 lines
4.0 KiB
JavaScript
Executable File
130 lines
4.0 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* SaltyFace Web App Server — Serves static HTML/JS to Chromium kiosk
|
|
*
|
|
* Lightweight HTTP server for SaltyFace web UI
|
|
* - Runs on localhost:3000 (configurable)
|
|
* - Serves public/ directory
|
|
* - Enables CORS for ROS bridge WebSocket
|
|
* - Suitable for systemd service or ROS2 launch
|
|
*
|
|
* Usage:
|
|
* node server.js [--port 3000] [--host 127.0.0.1]
|
|
*/
|
|
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const url = require('url');
|
|
|
|
// Configuration
|
|
const PORT = parseInt(process.argv.find(arg => arg.startsWith('--port='))?.split('=')[1] || 3000);
|
|
const HOST = process.argv.find(arg => arg.startsWith('--host='))?.split('=')[1] || '0.0.0.0';
|
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
|
|
// MIME types
|
|
const MIME_TYPES = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'application/javascript; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.json': 'application/json',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.svg': 'image/svg+xml',
|
|
'.woff': 'font/woff',
|
|
'.woff2': 'font/woff2',
|
|
'.ttf': 'font/ttf',
|
|
};
|
|
|
|
// HTTP server
|
|
const server = http.createServer((req, res) => {
|
|
// CORS headers for ROS bridge
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(200);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// Parse request URL
|
|
const parsedUrl = url.parse(req.url, true);
|
|
let pathname = parsedUrl.pathname;
|
|
|
|
// Default to index.html for root
|
|
if (pathname === '/') {
|
|
pathname = '/index.html';
|
|
}
|
|
|
|
// Security: prevent directory traversal
|
|
const filePath = path.normalize(path.join(PUBLIC_DIR, pathname));
|
|
if (!filePath.startsWith(PUBLIC_DIR)) {
|
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
res.end('Access denied');
|
|
return;
|
|
}
|
|
|
|
// Check if file exists
|
|
fs.stat(filePath, (err, stats) => {
|
|
if (err || !stats.isFile()) {
|
|
// File not found
|
|
console.log(`[SaltyFace] 404 ${pathname}`);
|
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
res.end('File not found');
|
|
return;
|
|
}
|
|
|
|
// Determine MIME type
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const mimeType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
|
|
// Serve file
|
|
res.writeHead(200, {
|
|
'Content-Type': mimeType,
|
|
'Content-Length': stats.size,
|
|
'Cache-Control': 'public, max-age=3600',
|
|
});
|
|
|
|
fs.createReadStream(filePath).pipe(res);
|
|
console.log(`[SaltyFace] 200 ${pathname} (${mimeType})`);
|
|
});
|
|
});
|
|
|
|
// Start server
|
|
server.listen(PORT, HOST, () => {
|
|
console.log(`
|
|
╔═══════════════════════════════════════════════════════════╗
|
|
║ SaltyFace Web App Server — Running ║
|
|
║ URL: http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT} │
|
|
║ Root: ${PUBLIC_DIR} │
|
|
║ Serving SaltyFace UI for Chromium kiosk ║
|
|
╚═══════════════════════════════════════════════════════════╝
|
|
`);
|
|
});
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGINT', () => {
|
|
console.log('\n[SaltyFace] Shutting down...');
|
|
server.close(() => {
|
|
console.log('[SaltyFace] Server closed');
|
|
process.exit(0);
|
|
});
|
|
});
|
|
|
|
process.on('SIGTERM', () => {
|
|
console.log('[SaltyFace] Received SIGTERM, shutting down...');
|
|
server.close(() => {
|
|
console.log('[SaltyFace] Server closed');
|
|
process.exit(0);
|
|
});
|
|
});
|
|
|
|
// Error handling
|
|
process.on('uncaughtException', (err) => {
|
|
console.error('[SaltyFace] Uncaught exception:', err);
|
|
process.exit(1);
|
|
});
|