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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
⚠
+
Alert
+
+
+
+
+
+
+
+
+
+
+
Good morning!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 = `
+
+
+
+
+
+
+
+
⚠
+
Alert
+
+
+
+
+
+
+
+
+
+
+
Good morning!
+
+
+
+
+
+
+
+ `;
+
+ 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 == "" {