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';