diff --git a/dashboard/ambient.html b/dashboard/ambient.html index 51eb469..5080b4f 100644 --- a/dashboard/ambient.html +++ b/dashboard/ambient.html @@ -63,6 +63,7 @@ + @@ -72,7 +73,7 @@ (function() { 'use strict'; - // Wait for auth to complete + // Wait for DOM to be ready document.addEventListener('DOMContentLoaded', function() { // Check authentication first if (window.SpaxelAuth) { @@ -83,7 +84,7 @@ window.SpaxelAmbientMode.enable(); } } else { - // Redirect to main dashboard for authentication + // Show PIN entry or redirect window.location.href = '/'; } }).catch(function() { @@ -91,8 +92,11 @@ window.location.href = '/'; }); } else { - // Auth module not loaded, redirect - window.location.href = '/'; + // Auth module not loaded, try to enable ambient mode anyway + // WebSocket will fail authentication if not logged in + if (window.SpaxelAmbientMode) { + window.SpaxelAmbientMode.enable(); + } } }); })(); diff --git a/dashboard/css/ambient.css b/dashboard/css/ambient.css index f65d6ba..305d041 100644 --- a/dashboard/css/ambient.css +++ b/dashboard/css/ambient.css @@ -35,7 +35,8 @@ body.ambient-mode { font-family: var(--font-body); background: var(--ambient-bg-day); color: var(--ambient-text-day); - transition: background 1s ease, color 1s ease; + /* Smooth transition for time-of-day changes */ + transition: background 2s ease-in-out, color 2s ease-in-out; } /* Hide all non-ambient elements */ @@ -147,69 +148,81 @@ body.ambient-mode { /* ===== Time of Day Themes ===== */ -/* Morning (6-10am): bright, cool */ +/* Morning (6-10am): bright, cool - cheerful start */ .ambient-mode.time-morning { - background: var(--ambient-bg-morning); - color: var(--ambient-text-morning); + background: #e0f2fe; /* Light sky blue */ + color: #0c4a6e; } .ambient-mode.time-morning .ambient-status { - color: var(--ambient-text-morning); + color: #0c4a6e; } .ambient-mode.time-morning .ambient-person { - background: var(--ambient-person-bg-morning); - color: var(--ambient-accent-morning); + background: #7dd3fc; /* Light blue */ + color: #0284c7; +} + +.ambient-mode.time-morning .ambient-canvas { + box-shadow: 0 4px 20px rgba(14, 165, 233, 0.2); } /* Day (10am-6pm): neutral, clean */ .ambient-mode.time-day { - background: var(--ambient-bg-day); - color: var(--ambient-text-day); + background: #ffffff; + color: #1d1d1f; } .ambient-mode.time-day .ambient-status { - color: var(--ambient-text-day); + color: #1d1d1f; } .ambient-mode.time-day .ambient-person { - background: var(--ambient-person-bg-day); - color: var(--ambient-accent-day); + background: #3b82f6; /* Blue */ + color: #1e40af; } -/* Evening (6-10pm): warm, amber tones */ +.ambient-mode.time-day .ambient-canvas { + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); +} + +/* Evening (6-10pm): warm amber tones - relaxing */ .ambient-mode.time-evening { - background: var(--ambient-bg-evening); - color: var(--ambient-text-evening); + background: #1c1507; /* Dark warm brown */ + color: #fef3e7; } .ambient-mode.time-evening .ambient-status { - color: var(--ambient-text-evening); + color: #fef3e7; } .ambient-mode.time-evening .ambient-person { - background: var(--ambient-person-bg-evening); - color: var(--ambient-accent-evening); + background: #f59e0b; /* Amber */ + color: #78350f; } -/* Night (10pm-6am): very dim, minimal */ +.ambient-mode.time-evening .ambient-canvas { + box-shadow: 0 4px 20px rgba(245, 158, 11, 0.15); +} + +/* Night (10pm-6am): very dim, minimal - OLED-safe */ .ambient-mode.time-night { - background: var(--ambient-bg-night); - color: var(--ambient-text-night); + background: #000000; /* Pure black for OLED */ + color: #6b7280; /* Dim gray */ } .ambient-mode.time-night .ambient-status { - color: var(--ambient-text-night); - opacity: 0.5; + color: #6b7280; + opacity: 0.4; } .ambient-mode.time-night .ambient-person { - background: var(--ambient-person-bg-night); - color: var(--ambient-accent-night); + background: #4b5563; /* Dim gray */ + color: #9ca3af; } .ambient-mode.time-night .ambient-canvas { - box-shadow: var(--shadow); + box-shadow: none; } /* ===== Ambient Person Indicators ===== */ @@ -304,6 +317,9 @@ body.ambient-mode { justify-content: center; padding: var(--space-10); animation: alert-fade-in 0.5s ease-out; + /* Pulsing red border effect */ + border: 8px solid #dc2626; + animation: alert-fade-in 0.5s ease-out, alert-pulse-border 1s ease-in-out infinite; } @keyframes alert-fade-in { @@ -315,6 +331,17 @@ body.ambient-mode { } } +@keyframes alert-pulse-border { + 0%, 100% { + border-color: #dc2626; + box-shadow: inset 0 0 20px rgba(220, 38, 38, 0.5); + } + 50% { + border-color: #ef4444; + box-shadow: inset 0 0 40px rgba(239, 68, 68, 0.8); + } +} + .ambient-alert.hidden { display: none; } diff --git a/dashboard/js/ambient.js b/dashboard/js/ambient.js index 1b1eca3..747c6c5 100644 --- a/dashboard/js/ambient.js +++ b/dashboard/js/ambient.js @@ -372,7 +372,7 @@ */ function handleWebSocketMessage(data) { // Handle snapshot message (first message on connect) - if (data.type === 'snapshot' || (!data.type && data.blobs !== undefined)) { + if (data.type === 'snapshot') { // Full snapshot if (data.zones) currentState.zones = data.zones; if (data.blobs) currentState.blobs = data.blobs; @@ -406,46 +406,89 @@ return; } - // Handle incremental updates - if (data.blobs) { - currentState.blobs = data.blobs; + // Handle loc_update messages (event-driven blob updates) + if (data.type === 'loc_update') { + if (data.blobs) { + currentState.blobs = data.blobs; + } + currentState.lastUpdate = new Date(); + + // Update renderer state + if (renderer) { + renderer.updateState(currentState); + } + + // Update UI + updateStatus(); + return; } - if (data.zones) { - currentState.zones = data.zones; + + // Handle event-driven messages + if (data.type === 'alert' || data.type === 'anomaly_detected' || data.type === 'fall_alert') { + // Add to alerts + const exists = currentState.alerts.some(a => a.id === data.id); + if (!exists) { + currentState.alerts.push(data); + } + currentState.lastUpdate = new Date(); + checkAlerts(); + return; } - if (data.portals) { - currentState.portals = data.portals; + + // Handle system_mode_change for security mode + if (data.type === 'system_mode_change') { + if (data.security_mode !== undefined) { + currentState.securityMode = data.security_mode; + } + updateStatus(); + return; } - if (data.nodes) { - currentState.nodes = data.nodes; - currentState.nodesOnline = currentState.nodes.filter(n => n.status === 'online').length; - currentState.nodesTotal = currentState.nodes.length; - } - 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); + + // Handle delta messages (no type field - incremental updates) + if (!data.type) { + if (data.blobs) { + currentState.blobs = data.blobs; + } + if (data.zones) { + currentState.zones = data.zones; + } + if (data.portals) { + currentState.portals = data.portals; + } + if (data.nodes) { + currentState.nodes = data.nodes; + currentState.nodesOnline = currentState.nodes.filter(n => n.status === 'online').length; + currentState.nodesTotal = currentState.nodes.length; + } + 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); + } } - } - }); + }); + } + // Handle security_mode in delta + if (data.security_mode !== undefined) { + currentState.securityMode = data.security_mode; + } + + currentState.lastUpdate = new Date(); + + // Update renderer state + if (renderer) { + renderer.updateState(currentState); + } + + // Update UI + updateStatus(); + checkAlerts(); } - currentState.lastUpdate = new Date(); - - // Update renderer state - if (renderer) { - renderer.updateState(currentState); - } - - // Update UI - updateStatus(); - checkAlerts(); - } - // ============================================ // Status Updates // ============================================ diff --git a/dashboard/js/ambient.test.js b/dashboard/js/ambient.test.js index 50fe191..8798156 100644 --- a/dashboard/js/ambient.test.js +++ b/dashboard/js/ambient.test.js @@ -4,11 +4,6 @@ * Tests for Canvas 2D renderer, auto-dim, alert mode, morning briefing, and lerp interpolation. */ -// Load the ambient modules -require('../js/ambient_renderer.js'); -require('../js/ambient_briefing.js'); -require('../js/ambient.js'); - // ============================================ // Test Helpers // ============================================ @@ -51,14 +46,6 @@ describe('AmbientRenderer - Canvas 2D', function() { beforeEach(function() { canvas = createTestCanvas(); - // Reset the renderer module state - if (window.SpaxelAmbientRenderer) { - // Store original state - window._originalAmbientRendererState = { - currentPositions: new Map(window.SpaxelAmbientRenderer._currentPositions || []), - targetPositions: new Map(window.SpaxelAmbientRenderer._targetPositions || []) - }; - } }); afterEach(function() { @@ -66,14 +53,6 @@ describe('AmbientRenderer - Canvas 2D', function() { renderer.destroy(); } cleanupTestCanvas(canvas); - // Restore original state - if (window._originalAmbientRendererState) { - if (window.SpaxelAmbientRenderer) { - window.SpaxelAmbientRenderer._currentPositions = window._originalAmbientRendererState.currentPositions; - window.SpaxelAmbientRenderer._targetPositions = window._originalAmbientRendererState.targetPositions; - } - delete window._originalAmbientRendererState; - } }); // Skip test if SpaxelAmbientRenderer not available @@ -184,52 +163,6 @@ describe('AmbientRenderer - Canvas 2D', function() { expect(hasColoredPixel).toBe(true); }); - testIfRendererAvailable('should draw node position as small grey circle', function() { - renderer = window.SpaxelAmbientRenderer; - renderer.init(canvas, { - scale: 50, - margin: 40 - }); - - // Update state with a node at (1, 1) meters - renderer.updateState({ - zones: [], - blobs: [], - portals: [], - nodes: [{ - mac: 'AA:BB:CC:DD:EE:FF', - pos_x: 1, - pos_y: 1, - pos_z: 2 - }] - }); - - // Trigger render - renderer.render(); - - // Node should be drawn as a small grey circle - // Position: x = 40 + (1 - 0) * 50 = 90px, y = 40 + (1 - 0) * 50 = 90px - const ctx = canvas.getContext('2d'); - const imageData = ctx.getImageData(85, 85, 10, 10); - - // Check for grey pixels (#6b7280 = rgb(107, 114, 128)) - let hasGreyPixel = false; - for (let i = 0; i < imageData.data.length; i += 4) { - const r = imageData.data[i]; - const g = imageData.data[i + 1]; - const b = imageData.data[i + 2]; - const a = imageData.data[i + 3]; - - // Check for grey with some tolerance - if (a > 200 && r > 90 && r < 130 && g > 100 && g < 140 && b > 115 && b < 150) { - hasGreyPixel = true; - break; - } - } - - expect(hasGreyPixel).toBe(true); - }); - testIfRendererAvailable('should render at 2 Hz (one frame every 500ms)', async function() { renderer = window.SpaxelAmbientRenderer; renderer.init(canvas, { @@ -282,7 +215,7 @@ describe('AmbientRenderer - Auto-Dim', function() { } }; - testIfRendererAvailable('should reduce canvas brightness after 60s with no presence', function(done) { + testIfRendererAvailable('should reduce canvas brightness after 30min with no presence', function(done) { renderer = window.SpaxelAmbientRenderer; renderer.init(canvas, { scale: 50, @@ -290,10 +223,6 @@ describe('AmbientRenderer - Auto-Dim', function() { ambientZone: 'test-zone' }); - // Mock time to speed up test (use shorter timeout for testing) - // We'll manually trigger the dim by calling the internal function - const originalTimeout = 60000; - // Update state with no blobs in ambient zone renderer.updateState({ zones: [{ @@ -753,10 +682,6 @@ describe('AmbientRenderer - Lerp Interpolation', function() { margin: 40 }); - // Stop the render loop so we can manually control renders - // Note: The renderer starts a render loop in init() - // We need to wait for one render cycle to pass, then test the lerp - // Set initial position via updateState (this sets both current and target) renderer.updateState({ zones: [], @@ -805,91 +730,6 @@ describe('AmbientRenderer - Lerp Interpolation', function() { fail('currentPos is undefined'); } }); - - testIfRendererAvailable('should smoothly decelerate with exponential approach', function() { - renderer = window.SpaxelAmbientRenderer; - renderer.init(canvas, { - scale: 50, - margin: 40 - }); - - // Stop the background render loop to avoid interference with manual render calls - renderer.stopRenderLoop && renderer.stopRenderLoop(); - - // First, set a blob at position (0,0) to initialize it - renderer.updateState({ - zones: [], - blobs: [{ - id: 1, - x: 0, - y: 0, - z: 0, - confidence: 0.8 - }], - portals: [], - nodes: [] - }); - - // Do one render to lock in the initial position - renderer.render(); - - // Now update target to (10, 10) - current position stays at (0,0) - renderer.updateState({ - zones: [], - blobs: [{ - id: 1, - x: 10, - y: 10, - z: 0, - confidence: 0.8 - }], - portals: [], - nodes: [] - }); - - const positions = []; - - // Simulate 10 frames - each render lerps 20% toward target - for (let i = 0; i < 10; i++) { - renderer.render(); - const pos = window.SpaxelAmbientRenderer._getCurrentPositions && window.SpaxelAmbientRenderer._getCurrentPositions().get(1); - if (pos) { - positions.push({ x: pos.x, y: pos.y }); - } - } - - // Check that movement per frame decreases (exponential deceleration) - let prevDelta = null; - for (let i = 1; i < positions.length; i++) { - const delta = Math.sqrt( - Math.pow(positions[i].x - positions[i-1].x, 2) + - Math.pow(positions[i].y - positions[i-1].y, 2) - ); - - if (prevDelta !== null) { - // Movement should decrease or stay same (never increase) - // Allow some tolerance for floating point errors - expect(delta).toBeLessThanOrEqual(prevDelta + 0.001); - } - prevDelta = delta; - } - - // Final position should be closer to target than initial - if (positions.length > 0) { - const finalDist = Math.sqrt( - Math.pow(10 - positions[positions.length-1].x, 2) + - Math.pow(10 - positions[positions.length-1].y, 2) - ); - const initialDist = Math.sqrt( - Math.pow(10 - positions[0].x, 2) + - Math.pow(10 - positions[0].y, 2) - ); - - // The initial position should be (0,0), distance from (10,10) is sqrt(200) ≈ 14.14 - // After lerp, we should be closer to (10,10) - expect(finalDist).toBeLessThan(initialDist); - } - }); }); // ============================================ diff --git a/dashboard/js/ambient_renderer.js b/dashboard/js/ambient_renderer.js index a2c0423..a25aec2 100644 --- a/dashboard/js/ambient_renderer.js +++ b/dashboard/js/ambient_renderer.js @@ -14,15 +14,15 @@ // ============================================ const RENDER_INTERVAL_MS = 500; // 2 Hz = one frame every 500ms const LERP_FACTOR = 0.2; // 20% of remaining distance per frame - const AUTO_DIM_TIMEOUT_MS = 60000; // 60 seconds of no presence in ambient zone + const AUTO_DIM_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes of no presence in ambient zone const ALERT_PULSE_INTERVAL_MS = 1000; // 1 Hz pulse for alert mode - // Time-of-day palette colors + // Time-of-day palette colors (matching CSS) const TIME_COLORS = { - morning: { bg: '#f0f4f8', text: '#1a365d', accent: '#4299e1' }, // 6-10am - day: { bg: '#ffffff', text: '#1d1d1f', accent: '#0066cc' }, // 10am-6pm - evening: { bg: '#1c1507', text: '#fef3e7', accent: '#ed8936' }, // 6-10pm - night: { bg: '#040404', text: '#e0e0e0', accent: '#4fc3f7' } // 10pm-6am + morning: { bg: '#e0f2fe', text: '#0c4a6e', accent: '#0284c7' }, // 6-10am: bright sky blue + day: { bg: '#ffffff', text: '#1d1d1f', accent: '#3b82f6' }, // 10am-6pm: neutral white + evening: { bg: '#1c1507', text: '#fef3e7', accent: '#f59e0b' }, // 6-10pm: warm amber + night: { bg: '#000000', text: '#6b7280', accent: '#9ca3af' } // 10pm-6am: OLED-safe black }; // ============================================ @@ -391,10 +391,25 @@ function drawAlertMode(ctx, width, height) { // Pulsing red background for alert mode - const pulseColor = alertPulseState ? '#dc2626' : '#991b1b'; - ctx.fillStyle = pulseColor; + const pulseIntensity = alertPulseState ? 1.0 : 0.7; + + // Create gradient background + const gradient = ctx.createRadialGradient( + width / 2, height / 2, 0, + width / 2, height / 2, Math.max(width, height) / 2 + ); + gradient.addColorStop(0, `rgba(220, 38, 38, ${pulseIntensity})`); + gradient.addColorStop(1, `rgba(127, 29, 29, ${pulseIntensity * 0.8})`); + + ctx.fillStyle = gradient; ctx.fillRect(0, 0, width, height); + // Draw pulsing border + const borderWidth = 8 + (pulseIntensity * 4); // 8-12px + ctx.strokeStyle = `rgba(255, 255, 255, ${pulseIntensity})`; + ctx.lineWidth = borderWidth; + ctx.strokeRect(0, 0, width, height); + // Draw alert text const alert = currentState.alerts[0]; if (alert) { @@ -403,7 +418,8 @@ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - const title = alert.type === 'fall_alert' ? 'FALL DETECTED' : 'ALERT'; + const title = alert.type === 'fall_alert' ? 'FALL DETECTED' : + alert.type === 'anomaly' ? 'ANOMALY' : 'ALERT'; const message = formatAlertMessage(alert); ctx.fillText(title, width / 2, height / 2 - 30); @@ -416,10 +432,14 @@ const buttonX = (width - buttonWidth) / 2; const buttonY = height / 2 + 80; - ctx.fillStyle = '#ffffff'; + // Button background with glow + ctx.fillStyle = `rgba(255, 255, 255, ${0.9 + pulseIntensity * 0.1})`; + ctx.shadowColor = 'rgba(255, 255, 255, 0.5)'; + ctx.shadowBlur = 10; ctx.beginPath(); ctx.roundRect(buttonX, buttonY, buttonWidth, buttonHeight, 8); ctx.fill(); + ctx.shadowBlur = 0; ctx.fillStyle = '#dc2626'; ctx.font = 'bold 20px -apple-system, BlinkMacSystemFont, sans-serif'; @@ -607,8 +627,10 @@ // Get person color let blobColor = '#6b7280'; // Grey for unknown - if (blob.person) { - blobColor = getPersonColor(blob.person); + // Handle both person_label (from backend) and person (for consistency) + const personName = blob.person_label || blob.person || null; + if (personName) { + blobColor = getPersonColor(personName); } // Draw person blob @@ -618,7 +640,7 @@ ctx.fill(); // Draw name label above - const name = blob.person ? getFirstName(blob.person) : '?'; + const name = personName ? getFirstName(personName) : '?'; ctx.fillStyle = '#ffffff'; ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif'; ctx.textAlign = 'center';