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>
211 lines
5.3 KiB
HTML
211 lines
5.3 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta name="theme-color" content="#050510">
|
|
<title>SaltyFace — Robot Expression UI</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
background: #050510;
|
|
color: #d1d5db;
|
|
font-family: 'Monaco', 'Courier New', monospace;
|
|
overflow: hidden;
|
|
user-select: none;
|
|
}
|
|
|
|
.container {
|
|
width: 100vw;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: #050510;
|
|
}
|
|
|
|
#canvas {
|
|
flex: 1;
|
|
display: block;
|
|
background: #050510;
|
|
touch-action: none;
|
|
}
|
|
|
|
.overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: space-between;
|
|
padding: 1rem;
|
|
text-align: left;
|
|
pointer-events: none;
|
|
font-size: 0.75rem;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.overlay.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.status-top-left {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.status-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.status-icon {
|
|
width: 1rem;
|
|
height: 1rem;
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
}
|
|
|
|
.battery-icon {
|
|
color: #fbbf24;
|
|
}
|
|
|
|
.state-icon {
|
|
color: #06b6d4;
|
|
}
|
|
|
|
.status-bottom-left {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.distance-icon {
|
|
color: #10b981;
|
|
}
|
|
|
|
.health-icon {
|
|
color: #ef4444;
|
|
}
|
|
|
|
.status-top-right {
|
|
position: absolute;
|
|
top: 1rem;
|
|
right: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
gap: 0.5rem;
|
|
color: #06b6d4;
|
|
}
|
|
|
|
.speed-display {
|
|
font-size: 1rem;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.tap-hint {
|
|
font-size: 0.65rem;
|
|
color: #6b7280;
|
|
}
|
|
|
|
.stats-container {
|
|
position: absolute;
|
|
bottom: 1rem;
|
|
right: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
font-size: 0.7rem;
|
|
color: #9ca3af;
|
|
background: rgba(5, 5, 16, 0.7);
|
|
padding: 0.5rem;
|
|
border-radius: 4px;
|
|
border: 1px solid #1f2937;
|
|
}
|
|
|
|
.connection-status {
|
|
position: absolute;
|
|
bottom: 1rem;
|
|
left: 1rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.7rem;
|
|
color: #9ca3af;
|
|
}
|
|
|
|
.connection-status.connected {
|
|
color: #10b981;
|
|
}
|
|
|
|
.connection-status.disconnected {
|
|
color: #ef4444;
|
|
}
|
|
|
|
.ws-status {
|
|
width: 0.5rem;
|
|
height: 0.5rem;
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<canvas id="canvas" width="1024" height="600"></canvas>
|
|
<div class="overlay" id="overlay">
|
|
<div class="status-top-left">
|
|
<div class="status-item">
|
|
<span class="status-icon battery-icon">⚡</span>
|
|
<span id="battery">--</span>%
|
|
</div>
|
|
<div class="status-item">
|
|
<span class="status-icon state-icon">●</span>
|
|
<span id="state">IDLE</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="status-bottom-left">
|
|
<div class="status-item">
|
|
<span class="status-icon distance-icon">●</span>
|
|
<span id="distance">--</span>m
|
|
</div>
|
|
<div class="status-item">
|
|
<span class="status-icon health-icon" id="health-icon">◇</span>
|
|
<span id="health">--</span>%
|
|
</div>
|
|
</div>
|
|
|
|
<div class="status-top-right">
|
|
<div class="speed-display" id="speed">-- m/s</div>
|
|
<div class="tap-hint">[tap to hide]</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="connection-status" id="ws-status">
|
|
<span class="ws-status" id="ws-dot"></span>
|
|
<span id="ws-label">DISCONNECTED</span>
|
|
</div>
|
|
|
|
<div class="stats-container">
|
|
<div>Emotion: <span id="emotion">HAPPY</span></div>
|
|
<div>Talking: <span id="talking">NO</span></div>
|
|
<div>Audio: <span id="audio-level">0%</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="module">
|
|
import { initSaltyFace } from './salty-face.js';
|
|
|
|
// Initialize SaltyFace web app
|
|
initSaltyFace();
|
|
</script>
|
|
</body>
|
|
</html>
|