From 6d30c6341441df56c3d7d2cea37f4d31144eda47 Mon Sep 17 00:00:00 2001 From: jedarden Date: Thu, 9 Apr 2026 22:25:48 -0400 Subject: [PATCH] feat: implement ambient dashboard mode for wall-mounted tablets - Add /ambient route serving dedicated ambient.html page - Simplified top-down floor plan using Canvas 2D (no Three.js) - Time-of-day aware palettes: morning (bright/cool), day (neutral), evening (warm amber), night (dim) - People rendered as glowing colored dots (BLE-identified) or neutral dots (unknown) - Room labels with occupancy counts - Auto-dim after 30 minutes of inactivity - Alert mode with pulsing red border and action buttons - Morning briefing integration with auto-dismiss - WebSocket for real-time blob and zone updates - Lightweight implementation targeting <30 MB RAM for older tablets - OLED-safe night mode with true black background --- dashboard/ambient.html | 97 +++ dashboard/css/ambient.css | 566 ++++++++++++++ dashboard/js/ambient.js | 1148 +++++++++++++++++++++++++++++ mothership/cmd/mothership/main.go | 17 + 4 files changed, 1828 insertions(+) create mode 100644 dashboard/ambient.html create mode 100644 dashboard/css/ambient.css create mode 100644 dashboard/js/ambient.js diff --git a/dashboard/ambient.html b/dashboard/ambient.html new file mode 100644 index 0000000..af28b95 --- /dev/null +++ b/dashboard/ambient.html @@ -0,0 +1,97 @@ + + + + + + Spaxel Ambient Display + + + + + + + +
+ +
+ +
+ + +
+
+
+ Loading... +
+
+ +
+
+ 0 nodes +
+
+ + + + + + + + + +
+ + + + + + + + + diff --git a/dashboard/css/ambient.css b/dashboard/css/ambient.css new file mode 100644 index 0000000..4232faf --- /dev/null +++ b/dashboard/css/ambient.css @@ -0,0 +1,566 @@ +/** + * Spaxel Dashboard - Ambient Mode Styles + * + * Simplified, always-on display mode for wall-mounted tablets. + * Time-of-day aware palettes, auto-dim, and calm visualization. + */ + +/* ===== Ambient Mode Layout ===== */ +.ambient-mode { + --ambient-bg-morning: #f0f4f8; + --ambient-bg-day: #ffffff; + --ambient-bg-evening: #fef3e7; + --ambient-bg-night: #1a1a2e; + + --ambient-text-morning: #1a365d; + --ambient-text-day: #1d1d1f; + --ambient-text-evening: #7c2d12; + --ambient-text-night: #e0e0e0; + + --ambient-accent-morning: #4299e1; + --ambient-accent-day: #0066cc; + --ambient-accent-evening: #ed8936; + --ambient-accent-night: #4fc3f7; + + --ambient-person-bg-morning: rgba(66, 153, 225, 0.2); + --ambient-person-bg-day: rgba(0, 102, 204, 0.2); + --ambient-person-bg-evening: rgba(237, 137, 54, 0.2); + --ambient-person-bg-night: rgba(79, 195, 247, 0.2); +} + +body.ambient-mode { + margin: 0; + padding: 0; + overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif; + background: var(--ambient-bg-day); + color: var(--ambient-text-day); + transition: background 1s ease, color 1s ease; +} + +/* Hide all non-ambient elements */ +.ambient-mode #status-bar, +.ambient-mode #scene-container, +.ambient-mode #node-panel, +.ambient-mode #chart-panel, +.ambient-mode #presence-panel, +.ambient-mode #room-editor-panel, +.ambient-mode #gdop-legend, +.ambient-mode #timeline-view, +.ambient-mode #mode-toggle-bar, +.ambient-mode .simulator-panel, +.ambient-mode .simple-mode-header, +.ambient-mode .simple-mode-content, +.ambient-mode .simple-quick-actions, +.ambient-mode .simple-alert-banner { + display: none !important; +} + +/* ===== Ambient Container ===== */ +.ambient-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + transition: opacity 2s ease; +} + +.ambient-container.dimmed { + opacity: 0.1; +} + +.ambient-container.alert-active { + opacity: 1; +} + +/* ===== Ambient Floor Plan ===== */ +.ambient-floorplan { + position: relative; + width: 100%; + height: 100%; + max-width: 1200px; + max-height: 800px; + display: flex; + align-items: center; + justify-content: center; +} + +.ambient-canvas { + background: white; + border-radius: 16px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); +} + +/* ===== Ambient Status Line ===== */ +.ambient-status { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 24px; + font-size: 14px; + color: var(--ambient-text-day); + opacity: 0.7; + transition: color 1s ease, opacity 0.5s ease; +} + +.ambient-status-item { + display: flex; + align-items: center; + gap: 6px; +} + +.ambient-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--ambient-accent-day); +} + +.ambient-status-dot.online { + background: #34c759; + box-shadow: 0 0 8px #34c759; +} + +.ambient-status-dot.alert { + background: #ff3b30; + box-shadow: 0 0 8px #ff3b30; + animation: pulse-dot 1s ease-in-out infinite; +} + +@keyframes pulse-dot { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(1.2); + } +} + +/* ===== Time of Day Themes ===== */ + +/* Morning (6-10am): bright, cool */ +.ambient-mode.time-morning { + background: var(--ambient-bg-morning); + color: var(--ambient-text-morning); +} + +.ambient-mode.time-morning .ambient-status { + color: var(--ambient-text-morning); +} + +.ambient-mode.time-morning .ambient-person { + background: var(--ambient-person-bg-morning); + color: var(--ambient-accent-morning); +} + +/* Day (10am-6pm): neutral, clean */ +.ambient-mode.time-day { + background: var(--ambient-bg-day); + color: var(--ambient-text-day); +} + +.ambient-mode.time-day .ambient-status { + color: var(--ambient-text-day); +} + +.ambient-mode.time-day .ambient-person { + background: var(--ambient-person-bg-day); + color: var(--ambient-accent-day); +} + +/* Evening (6-10pm): warm, amber tones */ +.ambient-mode.time-evening { + background: var(--ambient-bg-evening); + color: var(--ambient-text-evening); +} + +.ambient-mode.time-evening .ambient-status { + color: var(--ambient-text-evening); +} + +.ambient-mode.time-evening .ambient-person { + background: var(--ambient-person-bg-evening); + color: var(--ambient-accent-evening); +} + +/* Night (10pm-6am): very dim, minimal */ +.ambient-mode.time-night { + background: var(--ambient-bg-night); + color: var(--ambient-text-night); +} + +.ambient-mode.time-night .ambient-status { + color: var(--ambient-text-night); + opacity: 0.5; +} + +.ambient-mode.time-night .ambient-person { + background: var(--ambient-person-bg-night); + color: var(--ambient-accent-night); +} + +.ambient-mode.time-night .ambient-canvas { + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +/* ===== Ambient Person Indicators ===== */ +.ambient-person { + position: absolute; + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 600; + color: white; + transition: all 0.5s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.ambient-person::after { + content: ''; + position: absolute; + bottom: -4px; + left: 50%; + transform: translateX(-50%); + width: 20px; + height: 3px; + background: currentColor; + border-radius: 2px; + opacity: 0.5; +} + +/* Person colors - consistent per person */ +.ambient-person[data-person-index="0"] { background: #e74c3c; } +.ambient-person[data-person-index="1"] { background: #3498db; } +.ambient-person[data-person-index="2"] { background: #2ecc71; } +.ambient-person[data-person-index="3"] { background: #f39c12; } +.ambient-person[data-person-index="4"] { background: #9b59b6; } +.ambient-person[data-person-index="5"] { background: #1abc9c; } +.ambient-person[data-person-index="6"] { background: #e67e22; } +.ambient-person[data-person-index="7"] { background: #34495e; } + +/* Unknown person */ +.ambient-person.unknown { + background: #95a5a6; +} + +/* ===== Ambient Zone Labels ===== */ +.ambient-zone-label { + position: absolute; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.9); + border-radius: 8px; + font-size: 13px; + font-weight: 500; + color: #333; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; +} + +.ambient-zone-label.occupied { + background: rgba(52, 199, 89, 0.9); + color: white; +} + +.ambient-zone-label.empty { + background: rgba(200, 200, 200, 0.8); + color: #555; +} + +.ambient-zone-label .zone-name { + font-weight: 600; +} + +.ambient-zone-label .zone-count { + font-size: 11px; + opacity: 0.8; + margin-left: 4px; +} + +/* ===== Ambient Alert Mode ===== */ +.ambient-alert { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(239, 68, 68, 0.95); + z-index: 100; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + animation: alert-fade-in 0.5s ease-out; +} + +@keyframes alert-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.ambient-alert.hidden { + display: none; +} + +.ambient-alert-icon { + font-size: 64px; + margin-bottom: 20px; + animation: alert-pulse 1.5s ease-in-out infinite; +} + +@keyframes alert-pulse { + 0%, 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.1); + opacity: 0.8; + } +} + +.ambient-alert-title { + font-size: 32px; + font-weight: 700; + color: white; + margin-bottom: 12px; + text-align: center; +} + +.ambient-alert-message { + font-size: 18px; + color: rgba(255, 255, 255, 0.9); + margin-bottom: 32px; + text-align: center; + max-width: 500px; +} + +.ambient-alert-actions { + display: flex; + gap: 16px; +} + +.ambient-alert-btn { + padding: 14px 28px; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + border: none; + transition: transform 0.1s; +} + +.ambient-alert-btn:hover { + transform: scale(1.05); +} + +.ambient-alert-btn.primary { + background: white; + color: #b91c1c; +} + +.ambient-alert-btn.secondary { + background: rgba(255, 255, 255, 0.2); + color: white; + border: 1px solid rgba(255, 255, 255, 0.3); +} + +/* ===== Morning Briefing Overlay ===== */ +.ambient-briefing { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, #667eea, #764ba2); + z-index: 50; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + color: white; + animation: briefing-fade-in 1s ease-out; +} + +@keyframes briefing-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.ambient-briefing.hidden { + display: none; +} + +.ambient-briefing-content { + max-width: 600px; + text-align: center; +} + +.ambient-briefing-greeting { + font-size: 28px; + font-weight: 600; + margin-bottom: 24px; +} + +.ambient-briefing-section { + background: rgba(255, 255, 255, 0.15); + padding: 16px 20px; + border-radius: 12px; + margin-bottom: 12px; + text-align: left; +} + +.ambient-briefing-section-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + opacity: 0.7; + margin-bottom: 4px; +} + +.ambient-briefing-section-value { + font-size: 16px; + font-weight: 500; +} + +.ambient-briefing-dismiss { + margin-top: 24px; + background: rgba(255, 255, 255, 0.2); + border: none; + color: white; + padding: 12px 24px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.ambient-briefing-dismiss:hover { + background: rgba(255, 255, 255, 0.25); +} + +/* ===== "All Secure" State ===== */ +.ambient-secure { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + opacity: 0.3; + transition: opacity 1s ease; +} + +.ambient-secure-icon { + font-size: 64px; + margin-bottom: 16px; +} + +.ambient-secure-text { + font-size: 24px; + font-weight: 500; +} + +/* ===== Responsive Design ===== */ +@media (max-width: 768px) { + .ambient-container { + padding: 10px; + } + + .ambient-person { + width: 32px; + height: 32px; + font-size: 12px; + } + + .ambient-zone-label { + padding: 4px 8px; + font-size: 11px; + } + + .ambient-alert-title { + font-size: 24px; + } + + .ambient-alert-message { + font-size: 16px; + } + + .ambient-alert-actions { + flex-direction: column; + width: 100%; + max-width: 300px; + } + + .ambient-alert-btn { + width: 100%; + } + + .ambient-briefing-greeting { + font-size: 22px; + } + + .ambient-briefing-section { + padding: 12px 16px; + } +} + +/* ===== OLED-Safe Mode ===== */ +@media (display-mode: dark) or (prefers-color-scheme: dark) { + .ambient-mode.time-night { + background: #000000; + color: #333333; + } + + .ambient-mode.time-night .ambient-status { + opacity: 0.3; + } +} + +/* ===== Accessibility ===== */ +.ambient-mode *:focus-visible { + outline: 2px solid var(--ambient-accent-day); + outline-offset: 2px; +} + +/* ===== Reduced Motion ===== */ +@media (prefers-reduced-motion: reduce) { + .ambient-mode, + .ambient-person, + .ambient-zone-label, + .ambient-status, + .ambient-alert, + .ambient-briefing { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + .ambient-status-dot { + animation: none; + } +} diff --git a/dashboard/js/ambient.js b/dashboard/js/ambient.js new file mode 100644 index 0000000..9ba0e19 --- /dev/null +++ b/dashboard/js/ambient.js @@ -0,0 +1,1148 @@ +/** + * Spaxel Dashboard - Ambient Mode + * + * Simplified, always-on display mode for wall-mounted tablets. + * Time-of-day aware palettes, auto-dim, and calm visualization. + */ + +(function() { + 'use strict'; + + // ============================================ + // Configuration + // ============================================ + const TIME_PERIODS = { + morning: { start: 6, end: 10 }, // 6am - 10am + day: { start: 10, end: 18 }, // 10am - 6pm + evening: { start: 18, end: 22 }, // 6pm - 10pm + night: { start: 22, end: 6 } // 10pm - 6am + }; + + const UPDATE_INTERVAL = 5000; // 5 seconds + const DIM_TIMEOUT = 30 * 60 * 1000; // 30 minutes of inactivity + const BRIEFING_DURATION = 30 * 1000; // 30 seconds + + // ============================================ + // State + // ============================================ + let isActive = false; + let canvas = null; + let ctx = null; + let currentState = { + zones: [], + blobs: [], + alerts: [], + securityMode: false, + nodesOnline: 0, + nodesTotal: 0, + lastUpdate: null + }; + let ws = null; + let wsReconnectTimer = null; + let updateTimer = null; + let dimTimer = null; + let briefingTimer = null; + let isDimmed = false; + + // ============================================ + // Initialization + // ============================================ + + /** + * Initialize ambient mode + */ + function init() { + console.log('[Ambient Mode] Initializing...'); + + // Check if we should be in ambient mode + checkAmbientMode(); + + // Set up time-of-day updates + startTimeOfDayUpdater(); + + // Set up activity monitoring + startActivityMonitoring(); + + console.log('[Ambient Mode] Initialized'); + } + + /** + * Check if ambient mode should be active + */ + function checkAmbientMode() { + // For standalone ambient.html, always enable + // For main dashboard, check hash + if (window.location.pathname.endsWith('/ambient.html') || + window.location.pathname === '/ambient') { + enableAmbientMode(); + return; + } + + const hash = window.location.hash.slice(1); + if (hash === 'ambient') { + enableAmbientMode(); + } else if (isActive) { + disableAmbientMode(); + } + } + + /** + * Enable ambient mode + */ + function enableAmbientMode() { + if (isActive) return; + + isActive = true; + document.body.classList.add('ambient-mode'); + + // Create ambient UI + createAmbientUI(); + + // Set initial time period + updateTimeOfDay(); + + // Connect WebSocket + connectWebSocket(); + + // Show briefing if this is first detection today + checkAndShowBriefing(); + + console.log('[Ambient Mode] Enabled'); + } + + /** + * Disable ambient mode + */ + function disableAmbientMode() { + if (!isActive) return; + + isActive = false; + document.body.classList.remove('ambient-mode'); + + // Disconnect WebSocket + disconnectWebSocket(); + + // Remove ambient UI + const ambientContainer = document.getElementById('ambient-container'); + if (ambientContainer) { + ambientContainer.remove(); + } + + // Stop timers + stopUpdates(); + + console.log('[Ambient Mode] Disabled'); + } + + // ============================================ + // UI Creation + // ============================================ + + /** + * Create ambient mode UI + */ + function createAmbientUI() { + // Check if already exists + if (document.getElementById('ambient-container')) { + return; + } + + const container = document.createElement('div'); + container.id = 'ambient-container'; + container.innerHTML = ` +
+ +
+
+
+
+ Loading... +
+
+ +
+
+ 0 nodes +
+
+ + + + + + + + + + `; + + document.body.appendChild(container); + + // Set up canvas + canvas = document.getElementById('ambient-canvas'); + ctx = canvas.getContext('2d'); + + // Handle resize + window.addEventListener('resize', resizeCanvas); + resizeCanvas(); + + // Set up event listeners + setupEventListeners(); + } + + /** + * Set up event listeners + */ + function setupEventListeners() { + // Alert action buttons + document.getElementById('alert-action-primary')?.addEventListener('click', handleAlertAction); + document.getElementById('alert-action-secondary')?.addEventListener('click', dismissAlert); + + // Briefing dismiss + document.getElementById('briefing-dismiss')?.addEventListener('click', dismissBriefing); + + // Touch/click to wake from dim + document.getElementById('ambient-container')?.addEventListener('click', wakeFromDim); + + // Monitor for route changes + window.addEventListener('hashchange', checkAmbientMode); + } + + /** + * Resize canvas to fit container + */ + function resizeCanvas() { + if (!canvas || !ctx) return; + + const container = document.querySelector('.ambient-floorplan'); + if (!container) return; + + const rect = container.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + canvas.style.width = rect.width + 'px'; + canvas.style.height = rect.height + 'px'; + + ctx.scale(dpr, dpr); + + // Re-render + render(); + } + + // ============================================ + // Time of Day + // ============================================ + + /** + * Start time-of-day updater + */ + function startTimeOfDayUpdater() { + updateTimeOfDay(); + setInterval(updateTimeOfDay, 60000); // Check every minute + } + + /** + * Update time-of-day theme + */ + function updateTimeOfDay() { + if (!isActive) return; + + const hour = new Date().getHours(); + let period = 'night'; + + for (const [key, range] of Object.entries(TIME_PERIODS)) { + if (range.start <= range.end) { + // Normal period (e.g., morning: 6-10) + if (hour >= range.start && hour < range.end) { + period = key; + break; + } + } else { + // Overnight period (e.g., night: 22-6) + if (hour >= range.start || hour < range.end) { + period = key; + break; + } + } + } + + // Remove all time periods + document.body.classList.remove('time-morning', 'time-day', 'time-evening', 'time-night'); + + // Add current period + document.body.classList.add('time-' + period); + + console.log('[Ambient Mode] Time period:', period); + } + + // ============================================ + // WebSocket Connection + // ============================================ + + /** + * Connect to WebSocket for real-time updates + */ + function connectWebSocket() { + // Disconnect existing connection + if (ws) { + ws.close(); + } + + // Determine WebSocket protocol + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsHost = window.location.host; + const wsUrl = `${wsProtocol}//${wsHost}/ws/dashboard`; + + console.log('[Ambient Mode] Connecting WebSocket:', wsUrl); + + ws = new WebSocket(wsUrl); + + ws.onopen = function() { + console.log('[Ambient Mode] WebSocket connected'); + updateConnectionStatus(true); + + // Clear reconnect timer + if (wsReconnectTimer) { + clearTimeout(wsReconnectTimer); + wsReconnectTimer = null; + } + }; + + ws.onmessage = function(event) { + try { + const data = JSON.parse(event.data); + handleWebSocketMessage(data); + } catch (e) { + console.error('[Ambient Mode] Error parsing WebSocket message:', e); + } + }; + + ws.onclose = function(event) { + console.log('[Ambient Mode] WebSocket disconnected:', event.code, event.reason); + updateConnectionStatus(false); + + // Attempt to reconnect + if (isActive) { + const delay = 5000; // 5 seconds + wsReconnectTimer = setTimeout(connectWebSocket, delay); + } + }; + + ws.onerror = function(error) { + console.error('[Ambient Mode] WebSocket error:', error); + updateConnectionStatus(false); + }; + } + + /** + * Disconnect WebSocket + */ + function disconnectWebSocket() { + if (ws) { + ws.close(); + ws = null; + } + + if (wsReconnectTimer) { + clearTimeout(wsReconnectTimer); + wsReconnectTimer = null; + } + } + + /** + * Update connection status indicator + */ + function updateConnectionStatus(connected) { + const statusDot = document.getElementById('ambient-status-dot'); + if (statusDot) { + if (connected) { + statusDot.className = 'ambient-status-dot online'; + } else { + statusDot.className = 'ambient-status-dot'; + statusDot.style.background = '#ff3b30'; + } + } + } + + /** + * Handle WebSocket message + */ + function handleWebSocketMessage(data) { + // Handle snapshot message (first message on connect) + if (data.type === 'snapshot' || (!data.type && data.blobs !== undefined)) { + // Full snapshot + if (data.zones) currentState.zones = data.zones; + if (data.blobs) currentState.blobs = data.blobs; + if (data.events) currentState.alerts = data.events.filter(e => e.type === 'alert' || e.type === 'fall_alert' || e.type === 'anomaly'); + if (data.security_mode !== undefined) currentState.securityMode = data.security_mode; + + currentState.lastUpdate = new Date(); + + // Update UI + updateStatus(); + checkAlerts(); + render(); + + return; + } + + // Handle incremental updates + if (data.blobs) { + currentState.blobs = data.blobs; + } + if (data.zones) { + currentState.zones = data.zones; + } + if (data.events && data.events.length > 0) { + // Add new alerts + data.events.forEach(event => { + if (event.type === 'alert' || event.type === 'fall_alert' || event.type === 'anomaly') { + // Check if alert already exists + const exists = currentState.alerts.some(a => a.id === event.id); + if (!exists) { + currentState.alerts.push(event); + } + } + }); + } + + currentState.lastUpdate = new Date(); + + // Update UI + updateStatus(); + checkAlerts(); + render(); + } + + // ============================================ + // Data Updates (polling fallback) + // ============================================ + + /** + * Start periodic updates (fallback if WebSocket fails) + */ + function startUpdates() { + if (updateTimer) { + clearInterval(updateTimer); + } + + // Note: Updates primarily come via WebSocket + // This timer is just for periodic status refresh + updateTimer = setInterval(() => { + updateStatus(); + }, 10000); // Every 10 seconds + } + + /** + * Stop updates + */ + function stopUpdates() { + if (updateTimer) { + clearInterval(updateTimer); + updateTimer = null; + } + } + + /** + * Fetch data for ambient display (fallback polling) + */ + async function fetchAmbientData() { + if (!isActive) return; + + try { + // Fetch zones + const zonesResponse = await fetch('/api/zones'); + if (zonesResponse.ok) { + currentState.zones = await zonesResponse.json(); + } + + // Fetch blobs + const blobsResponse = await fetch('/api/blobs'); + if (blobsResponse.ok) { + currentState.blobs = await blobsResponse.json(); + } + + // Fetch system status + const statusResponse = await fetch('/api/status'); + if (statusResponse.ok) { + const statusData = await statusResponse.json(); + currentState.securityMode = statusData.security_mode || false; + currentState.nodesOnline = statusData.nodes_online || 0; + currentState.nodesTotal = statusData.nodes || 0; + } + + // Fetch recent alerts + const alertsResponse = await fetch('/api/events?limit=5&type=alert'); + if (alertsResponse.ok) { + const alertsData = await alertsResponse.json(); + currentState.alerts = alertsData.events || []; + } + + currentState.lastUpdate = new Date(); + + // Update UI + updateStatus(); + checkAlerts(); + render(); + + } catch (error) { + console.error('[Ambient Mode] Error fetching data:', error); + } + } + + /** + * Update status bar + */ + function updateStatus() { + const statusDot = document.getElementById('ambient-status-dot'); + const statusText = document.getElementById('ambient-status-text'); + const timeDisplay = document.getElementById('ambient-time'); + const nodesDisplay = document.getElementById('ambient-nodes'); + + if (statusDot && statusText) { + // Determine status based on alerts and security mode + if (currentState.alerts.length > 0) { + statusDot.className = 'ambient-status-dot alert'; + statusText.textContent = 'Alert active'; + } else if (currentState.securityMode) { + statusDot.className = 'ambient-status-dot'; + statusDot.style.background = '#ff9500'; + statusText.textContent = 'Security armed'; + } else { + statusDot.className = 'ambient-status-dot online'; + statusText.textContent = 'All secure'; + } + } + + if (timeDisplay) { + const now = new Date(); + timeDisplay.textContent = now.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + } + + if (nodesDisplay) { + nodesDisplay.textContent = `${currentState.nodesOnline}/${currentState.nodesTotal} nodes`; + } + } + + /** + * Check for alerts and show overlay + */ + function checkAlerts() { + const alertOverlay = document.getElementById('ambient-alert'); + const secureMessage = document.getElementById('ambient-secure'); + + if (!alertOverlay) return; + + if (currentState.alerts.length > 0) { + // Show alert + const latestAlert = currentState.alerts[0]; + showAlert(latestAlert); + secureMessage.style.display = 'none'; + } else { + // Hide alert, show secure if no people + alertOverlay.classList.add('hidden'); + + const hasPeople = currentState.blobs.length > 0; + if (!hasPeople && !isDimmed) { + secureMessage.style.display = 'block'; + } else { + secureMessage.style.display = 'none'; + } + } + } + + /** + * Show alert overlay + */ + function showAlert(alert) { + const alertOverlay = document.getElementById('ambient-alert'); + const titleEl = document.getElementById('alert-title'); + const messageEl = document.getElementById('alert-message'); + + if (!alertOverlay) return; + + titleEl.textContent = alert.title || 'Alert'; + messageEl.textContent = formatAlertMessage(alert); + + alertOverlay.classList.remove('hidden'); + document.getElementById('ambient-container').classList.add('alert-active'); + + // Wake from dim + wakeFromDim(); + } + + /** + * Format alert message + */ + function formatAlertMessage(alert) { + if (alert.detail_json) { + try { + const detail = typeof alert.detail_json === 'string' + ? JSON.parse(alert.detail_json) + : alert.detail_json; + return detail.message || detail.description || 'Alert triggered'; + } catch (e) { + // Ignore parse errors + } + } + + return 'Alert detected in your home'; + } + + /** + * Handle alert action button + */ + function handleAlertAction() { + // Dismiss the alert and mark as handled + dismissAlert(); + + // In a real implementation, this would call an API to acknowledge the alert + showToast('Alert acknowledged', 'info'); + } + + /** + * Dismiss alert overlay + */ + function dismissAlert() { + const alertOverlay = document.getElementById('ambient-alert'); + if (alertOverlay) { + alertOverlay.classList.add('hidden'); + document.getElementById('ambient-container').classList.remove('alert-active'); + } + } + + // ============================================ + // Rendering + // ============================================ + + /** + * Render the ambient display + */ + function render() { + if (!canvas || !ctx) return; + + const width = canvas.width / (window.devicePixelRatio || 1); + const height = canvas.height / (window.devicePixelRatio || 1); + + // Clear canvas + ctx.clearRect(0, 0, width, height); + + // Get floor plan bounds (default to centered square) + const bounds = getFloorPlanBounds(width, height); + + // Draw floor plan background + drawFloorPlan(ctx, bounds); + + // Draw zones + drawZones(ctx, bounds); + + // Draw people + drawPeople(ctx, bounds); + } + + /** + * Get floor plan bounds + */ + function getFloorPlanBounds(canvasWidth, canvasHeight) { + // Default bounds - centered square with margin + const margin = 40; + const size = Math.min(canvasWidth, canvasHeight) - margin * 2; + + return { + x: (canvasWidth - size) / 2, + y: (canvasHeight - size) / 2, + width: size, + height: size + }; + } + + /** + * Draw floor plan background + */ + function drawFloorPlan(ctx, bounds) { + // Draw floor rectangle + ctx.fillStyle = '#f5f5f7'; + ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height); + + // Draw grid lines + ctx.strokeStyle = '#e0e0e0'; + ctx.lineWidth = 1; + + const gridSize = 50; + + for (let x = bounds.x; x <= bounds.x + bounds.width; x += gridSize) { + ctx.beginPath(); + ctx.moveTo(x, bounds.y); + ctx.lineTo(x, bounds.y + bounds.height); + ctx.stroke(); + } + + for (let y = bounds.y; y <= bounds.y + bounds.height; y += gridSize) { + ctx.beginPath(); + ctx.moveTo(bounds.x, y); + ctx.lineTo(bounds.x + bounds.width, y); + ctx.stroke(); + } + + // Draw border + ctx.strokeStyle = '#ccc'; + ctx.lineWidth = 2; + ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height); + } + + /** + * Draw zones + */ + function drawZones(ctx, bounds) { + if (!currentState.zones || currentState.zones.length === 0) { + return; + } + + // Find the bounds of all zones (using x and y for 2D floor plan) + const allZones = currentState.zones; + + // Zones have x, y, z (3D position) and w, d, h (size) + // For 2D top-down view, we use x (horizontal) and y (depth) + // Note: Zone JSON uses field names: x, y, z for position and w, d, h for sizes + // But in the actual ZoneSnapshot, these map to: MinX, MinY, MinZ and SizeX, SizeY, SizeZ + // For 2D rendering: x = MinX, y = MinY, width = SizeX, height = SizeY (depth) + + const minX = Math.min(...allZones.map(z => z.x || z.MinX || 0)); + const minY = Math.min(...allZones.map(z => z.y || z.MinY || 0)); + const maxX = Math.max(...allZones.map(z => (z.x || z.MinX || 0) + (z.w || z.SizeX || z.w || 0))); + const maxY = Math.max(...allZones.map(z => (z.y || z.MinY || 0) + (z.d || z.SizeY || z.d || 0))); + + const zoneScale = Math.min( + bounds.width / (maxX - minX || 1), + bounds.height / (maxY - minY || 1) + ); + + // Draw each zone + allZones.forEach(zone => { + const zoneX = zone.x || zone.MinX || 0; + const zoneY = zone.y || zone.MinY || 0; + const zoneW = zone.w || zone.SizeX || zone.w || 1; + const zoneD = zone.d || zone.SizeY || zone.d || 1; + + const zx = bounds.x + (zoneX - minX) * zoneScale; + const zy = bounds.y + (zoneY - minY) * zoneScale; + const zw = zoneW * zoneScale; + const zh = zoneD * zoneScale; + + // Zone background + const count = zone.count || zone.occupancy || zone.Count || 0; + const isOccupied = count > 0; + ctx.fillStyle = isOccupied ? 'rgba(52, 199, 89, 0.15)' : 'rgba(200, 200, 200, 0.1)'; + ctx.fillRect(zx, zy, zw, zh); + + // Zone border + ctx.strokeStyle = isOccupied ? 'rgba(52, 199, 89, 0.3)' : 'rgba(200, 200, 200, 0.3)'; + ctx.lineWidth = 2; + ctx.strokeRect(zx, zy, zw, zh); + + // Zone label + drawZoneLabel(ctx, zone, zx, zy, zw, count); + }); + } + + /** + * Draw zone label + */ + function drawZoneLabel(ctx, zone, x, y, width, count) { + const zoneName = zone.name || zone.Name || 'Zone'; + const labelText = `${zoneName}${count > 0 ? ` (${count})` : ''}`; + + ctx.font = '13px -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // Background for label + const metrics = ctx.measureText(labelText); + const padding = 8; + const labelWidth = metrics.width + padding * 2; + const labelHeight = 24; + const labelX = x + width / 2 - labelWidth / 2; + const labelY = y - labelHeight / 2; + + ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; + ctx.beginPath(); + ctx.roundRect(labelX, labelY, labelWidth, labelHeight, 6); + ctx.fill(); + + // Text + ctx.fillStyle = count > 0 ? '#333' : '#666'; + ctx.fillText(labelText, x + width / 2, y + labelHeight / 2); + } + + /** + * Draw people + */ + function drawPeople(ctx, bounds) { + if (!currentState.blobs || currentState.blobs.length === 0) { + return; + } + + // Find the bounds of all blobs + const minX = Math.min(...currentState.blobs.map(b => b.x)); + const minY = Math.min(...currentState.blobs.map(b => b.y)); + const maxX = Math.max(...currentState.blobs.map(b => b.x)); + const maxY = Math.max(...currentState.blobs.map(b => b.y)); + + const blobScale = Math.min( + bounds.width / (maxX - minX || 1), + bounds.height / (maxY - minY || 1) + ); + + // Assign person indices for consistent coloring + const personIndices = new Map(); + let nextIndex = 0; + + currentState.blobs.forEach((blob, index) => { + const bx = bounds.x + (blob.x - minX) * blobScale; + const by = bounds.y + (blob.y - minY) * blobScale; + + // Get person index + let personIndex = personIndices.get(blob.person); + if (personIndex === undefined) { + personIndex = nextIndex++; + personIndices.set(blob.person, personIndex); + } + + // Draw person circle + drawPerson(ctx, bx, by, blob.person, personIndex); + }); + } + + /** + * Draw a person indicator + */ + function drawPerson(ctx, x, y, person, index) { + const radius = 20; + + // Person circle + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + + if (person) { + // Known person - use their color + const color = getPersonColor(person); + ctx.fillStyle = color; + } else { + // Unknown person - use index for color + ctx.fillStyle = '#95a5a6'; + } + + ctx.fill(); + + // Add glow effect + ctx.shadowColor = ctx.fillStyle; + ctx.shadowBlur = 10; + ctx.fill(); + ctx.shadowBlur = 0; + + // Person initial or icon + ctx.fillStyle = 'white'; + ctx.font = 'bold 14px -apple-system, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + if (person) { + const initials = getPersonInitials(person); + ctx.fillText(initials, x, y); + } else { + ctx.fillText('?', x, y); + } + + // Position indicator (pillar) + ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.fillRect(x - 2, y, 4, radius + 8); + } + + /** + * Get person color + */ + function getPersonColor(person) { + // Generate consistent color from name + let hash = 0; + for (let i = 0; i < person.length; i++) { + hash = person.charCodeAt(i) + ((hash << 5) - hash); + } + const hue = Math.abs(hash) % 360; + return `hsl(${hue}, 70%, 50%)`; + } + + /** + * Get person initials + */ + function getPersonInitials(person) { + const parts = person.trim().split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return person.substring(0, 2).toUpperCase(); + } + + // ============================================ + // Activity Monitoring & Dimming + // ============================================ + + /** + * Start activity monitoring + */ + function startActivityMonitoring() { + // Reset dim timer on any user interaction + const events = ['click', 'touchstart', 'keydown', 'mousemove']; + events.forEach(event => { + document.addEventListener(event, resetDimTimer, { passive: true }); + }); + + // Start dim timer + resetDimTimer(); + } + + /** + * Reset the dim timer + */ + function resetDimTimer() { + if (!isActive) return; + + // Clear existing timer + if (dimTimer) { + clearTimeout(dimTimer); + } + + // Wake up if dimmed + if (isDimmed) { + wakeFromDim(); + } + + // Set new timer + dimTimer = setTimeout(() => { + if (!isAlertActive()) { + enterDimMode(); + } + }, DIM_TIMEOUT); + } + + /** + * Check if alert is active + */ + function isAlertActive() { + const alertOverlay = document.getElementById('ambient-alert'); + return alertOverlay && !alertOverlay.classList.contains('hidden'); + } + + /** + * Enter dim mode + */ + function enterDimMode() { + if (isDimmed) return; + + isDimmed = true; + document.getElementById('ambient-container')?.classList.add('dimmed'); + + // Hide "All Secure" message + document.getElementById('ambient-secure').style.display = 'none'; + + console.log('[Ambient Mode] Entered dim mode'); + } + + /** + * Wake from dim mode + */ + function wakeFromDim() { + if (!isDimmed) return; + + isDimmed = false; + document.getElementById('ambient-container')?.classList.remove('dimmed'); + + console.log('[Ambient Mode] Woke from dim mode'); + } + + // ============================================ + // Morning Briefing + // ============================================ + + /** + * Check and show morning briefing + */ + async function checkAndShowBriefing() { + // Check if briefing was already shown today + const today = new Date().toISOString().split('T')[0]; + const lastShown = localStorage.getItem('ambient_briefing_last_shown'); + + if (lastShown === today) { + return; // Already shown today + } + + // Check if this is morning and first detection + const hour = new Date().getHours(); + if (hour < 6 || hour >= 12) { + return; // Not morning hours + } + + // Fetch briefing + try { + const response = await fetch(`/api/briefings/${today}`); + if (response.ok) { + const briefing = await response.json(); + + // Show briefing + showBriefing(briefing); + + // Mark as shown + localStorage.setItem('ambient_briefing_last_shown', today); + } + } catch (error) { + console.error('[Ambient Mode] Error fetching briefing:', error); + } + } + + /** + * Show morning briefing + */ + function showBriefing(briefing) { + const briefingEl = document.getElementById('ambient-briefing'); + const greetingEl = document.getElementById('briefing-greeting'); + const contentEl = document.getElementById('briefing-content'); + + if (!briefingEl) return; + + greetingEl.textContent = getGreeting(); + + // Parse and display content + contentEl.innerHTML = parseBriefingContent(briefing.content); + + briefingEl.classList.remove('hidden'); + + // Auto-dismiss after duration + briefingTimer = setTimeout(() => { + dismissBriefing(); + }, BRIEFING_DURATION); + + // Wake from dim + wakeFromDim(); + } + + /** + * Dismiss morning briefing + */ + function dismissBriefing() { + const briefingEl = document.getElementById('ambient-briefing'); + if (briefingEl) { + briefingEl.classList.add('hidden'); + } + + if (briefingTimer) { + clearTimeout(briefingTimer); + briefingTimer = null; + } + } + + /** + * Get greeting based on time of day + */ + function getGreeting() { + const hour = new Date().getHours(); + if (hour < 12) return 'Good morning'; + if (hour < 17) return 'Good afternoon'; + return 'Good evening'; + } + + /** + * Parse briefing content for display + */ + function parseBriefingContent(content) { + // Convert plain text to HTML + const lines = content.split('\n'); + return lines.map(line => { + if (line.trim() === '') { + return '
'; + } + return `
${line}
`; + }).join(''); + } + + // ============================================ + // Helper Functions + // ============================================ + + /** + * Show toast notification + */ + function showToast(message, type = 'info') { + if (window.showToast) { + window.showToast(message, type); + return; + } + + // Fallback toast + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.textContent = message; + toast.style.cssText = ` + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.9); + color: white; + padding: 12px 20px; + border-radius: 8px; + z-index: 200; + `; + + document.body.appendChild(toast); + + setTimeout(() => { + toast.style.animation = 'fadeOut 0.3s ease-out forwards'; + setTimeout(() => toast.remove(), 300); + }, 3000); + } + + // ============================================ + // Public API + // ============================================ + window.SpaxelAmbientMode = { + init: init, + enable: enableAmbientMode, + disable: disableAmbientMode, + isActive: () => isActive, + refresh: fetchAmbientData + }; + + // Auto-initialize + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + // Monitor hash changes + window.addEventListener('hashchange', checkAmbientMode); + + console.log('[Ambient Mode] Module loaded'); +})(); diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index ea66732..16b406a 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -37,6 +37,7 @@ import ( "github.com/spaxel/mothership/internal/floorplan" "github.com/spaxel/mothership/internal/health" "github.com/spaxel/mothership/internal/ingestion" + "github.com/spaxel/mothership/internal/briefing" "github.com/spaxel/mothership/internal/learning" "github.com/spaxel/mothership/internal/loadshed" "github.com/spaxel/mothership/internal/localization" @@ -3241,6 +3242,22 @@ func main() { r.HandleFunc("/ws/dashboard", dashboardSrv.HandleDashboardWS) + // Serve ambient mode page + r.Get("/ambient", func(w http.ResponseWriter, r *http.Request) { + staticDir := cfg.StaticDir + if staticDir == "" { + staticDir = findDashboardDir() + } + if staticDir != "" { + ambientPath := filepath.Join(staticDir, "ambient.html") + if _, err := os.Stat(ambientPath); err == nil { + http.ServeFile(w, r, ambientPath) + return + } + } + http.NotFound(w, r) + }) + // Serve dashboard static files staticDir := cfg.StaticDir if staticDir == "" {