/** * Spaxel Dashboard - Main Application * * Phase 1 skeleton: 3D scene with ground grid, OrbitControls, * WebSocket connection, and amplitude bar chart visualization. */ (function() { 'use strict'; // ============================================ // Configuration // ============================================ const CONFIG = { wsReconnectDelay: 3000, gridWidth: 10, // meters gridDepth: 10, // meters gridDivisions: 20, cameraFov: 60, cameraNear: 0.1, cameraFar: 1000, cameraInitial: { x: 8, y: 8, z: 8 }, chartBars: 64, // number of subcarriers chartUpdateMs: 100, // update chart at 10 Hz max tsMaxPoints: 360, // max time series samples per link (~60s at 6Hz) tsMinIntervalMs: 100, // min ms between time series samples drTsWindowMs: 10000, // deltaRMS time series window: 10 seconds drTsMaxPoints: 100, // max deltaRMS samples per link drThreshold: 0.02, // DefaultDeltaRMSThreshold healthPollIntervalMs: 10000, // poll /api/links every 10 seconds diurnalPollIntervalMs: 30000 // poll /api/diurnal/status every 30 seconds }; // ============================================ // State // ============================================ const state = { ws: null, wsConnected: false, nodes: new Map(), // MAC -> { mac, firmware, chip, lastSeen } links: new Map(), // linkID -> { nodeMAC, peerMAC, lastFrame, lastCSI, motionDetected, deltaRMS, ampHistory, lastAmpSample } selectedLinkID: null, presenceSelectedLinkID: null, drHistory: new Map(), // linkID -> [{ t: number, rms: number }] lastChartUpdate: 0, frameCount: 0, lastFpsTime: performance.now(), // System health tracking systemHealth: 0, worstLinkID: null, worstLinkScore: 1.0, // Diurnal learning tracking diurnalStatus: new Map(), // linkID -> { is_learning, progress, is_ready, days_remaining } diurnalPollTimer: null, healthPollTimer: null }; // ============================================ // Three.js Scene Setup // ============================================ let scene, camera, renderer, controls, gridHelper, axesHelper, clock; function initScene() { const container = document.getElementById('scene-container'); // Scene scene = new THREE.Scene(); scene.background = new THREE.Color(0x1a1a2e); // Camera camera = new THREE.PerspectiveCamera( CONFIG.cameraFov, window.innerWidth / window.innerHeight, CONFIG.cameraNear, CONFIG.cameraFar ); camera.position.set( CONFIG.cameraInitial.x, CONFIG.cameraInitial.y, CONFIG.cameraInitial.z ); // Renderer renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(window.devicePixelRatio); container.appendChild(renderer.domElement); // OrbitControls controls = new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.screenSpacePanning = true; controls.minDistance = 2; controls.maxDistance = 50; controls.maxPolarAngle = Math.PI / 2 + 0.3; // Allow slight below-ground view // Grid helper (XZ plane, Y-up) gridHelper = new THREE.GridHelper( CONFIG.gridWidth, CONFIG.gridDivisions, 0x444466, // center line color 0x333344 // grid line color ); scene.add(gridHelper); // Axes helper for orientation axesHelper = new THREE.AxesHelper(2); axesHelper.position.set(-CONFIG.gridWidth / 2, 0.01, -CONFIG.gridDepth / 2); scene.add(axesHelper); // Ambient light const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); // Directional light const directionalLight = new THREE.DirectionalLight(0xffffff, 0.4); directionalLight.position.set(5, 10, 5); scene.add(directionalLight); // Handle window resize window.addEventListener('resize', onWindowResize); // Clock for animation mixer delta clock = new THREE.Clock(); // Initialise 3-D spatial visualisation layer Viz3D.init(scene, camera, controls); // Initialise placement (TransformControls, GDOP, room editor) if (window.Placement) { Placement.init(scene, camera, renderer, controls); } console.log('[Spaxel] Scene initialized'); } function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); } function animate() { requestAnimationFrame(animate); controls.update(); Viz3D.update(); if (window.Placement) Placement.update(); renderer.render(scene, camera); updateFPS(); } // ============================================ // System Quality Gauge // ============================================ function updateQualityGauge(score, linkCount, worstLinkID, worstScore) { const valueEl = document.getElementById('quality-value'); const fillEl = document.getElementById('quality-gauge-fill'); const linkCountEl = document.getElementById('quality-link-count'); const worstLinkEl = document.getElementById('quality-worst-link'); const worstScoreEl = document.getElementById('quality-worst-score'); if (!valueEl || !fillEl) return; // Update percentage display const pct = Math.round(score * 100); valueEl.textContent = pct + '%'; // Update circular gauge (stroke-dasharray: circumference = 2 * PI * r = ~81.7 for r=13) const circumference = 2 * Math.PI * 13; const dashLength = (score * circumference).toFixed(1); fillEl.setAttribute('stroke-dasharray', dashLength + ' ' + circumference); // Update color based on score let color; if (score >= 0.7) { color = '#66bb6a'; // green } else if (score >= 0.4) { color = '#eab308'; // yellow } else { color = '#ef4444'; // red } fillEl.setAttribute('stroke', color); // Update tooltip if (linkCountEl) linkCountEl.textContent = linkCount; if (worstLinkEl) worstLinkEl.textContent = worstLinkID ? abbreviateLinkID(worstLinkID) : '--'; if (worstScoreEl) worstScoreEl.textContent = worstScore !== null ? Math.round(worstScore * 100) + '%' : '--'; } function startHealthPolling() { if (state.healthPollTimer) { clearInterval(state.healthPollTimer); } fetchLinkHealth(); state.healthPollTimer = setInterval(fetchLinkHealth, CONFIG.healthPollIntervalMs); } function fetchLinkHealth() { fetch('/api/links') .then(function(res) { return res.json(); }) .then(function(links) { handleLinkHealthUpdate(links); }) .catch(function(err) { console.error('[Spaxel] Failed to fetch link health:', err); }); } function handleLinkHealthUpdate(links) { if (!links || links.length === 0) { state.systemHealth = 0; state.worstLinkID = null; state.worstLinkScore = 1.0; updateQualityGauge(0, 0, null, null); return; } // Calculate system health (weighted average of all links) var totalScore = 0; var worstScore = 1.0; var worstID = null; links.forEach(function(link) { var score = link.health_score !== undefined ? link.health_score : 0.5; totalScore += score; if (score < worstScore) { worstScore = score; worstID = link.link_id; } }); state.systemHealth = totalScore / links.length; state.worstLinkID = worstID; state.worstLinkScore = worstScore; updateQualityGauge(state.systemHealth, links.length, worstID, worstScore); // Also update 3D visualization if (window.Viz3D && window.Viz3D.updateLinkHealth) { Viz3D.updateLinkHealth(links); } // Also update LinkHealth panel if (window.LinkHealth && window.LinkHealth.updateLinkHealth) { LinkHealth.updateLinkHealth(links); } } // ============================================ // Diurnal Learning Status // ============================================ function startDiurnalPolling() { if (state.diurnalPollTimer) { clearInterval(state.diurnalPollTimer); } fetchDiurnalStatus(); state.diurnalPollTimer = setInterval(fetchDiurnalStatus, CONFIG.diurnalPollIntervalMs); } function fetchDiurnalStatus() { fetch('/api/diurnal/status') .then(function(res) { return res.json(); }) .then(function(statuses) { handleDiurnalStatusUpdate(statuses); }) .catch(function(err) { console.error('[Spaxel] Failed to fetch diurnal status:', err); }); } function handleDiurnalStatusUpdate(statuses) { if (!statuses || statuses.length === 0) { updateDiurnalBanner(null); return; } // Find the link with the longest remaining learning time var worstStatus = null; statuses.forEach(function(status) { state.diurnalStatus.set(status.link_id, status); if (!worstStatus || status.days_remaining > worstStatus.days_remaining) { if (status.is_learning) { worstStatus = status; } } }); updateDiurnalBanner(worstStatus); } function updateDiurnalBanner(status) { var banner = document.getElementById('diurnal-banner'); var message = document.getElementById('diurnal-message'); var progress = document.getElementById('diurnal-progress'); var daysLeft = document.getElementById('diurnal-days-left'); if (!banner) return; if (!status || !status.is_learning) { banner.classList.remove('visible'); return; } banner.classList.add('visible'); if (message) { message.textContent = 'Learning your home\'s daily patterns...'; } if (progress) { var pct = Math.min(100, Math.max(0, status.progress || 0)); progress.style.width = pct + '%'; } if (daysLeft) { var days = Math.ceil(status.days_remaining || 0); if (days > 0) { daysLeft.textContent = days + (days === 1 ? ' day left' : ' days left'); } else { daysLeft.textContent = 'Almost ready...'; } } } function showToast(message, type) { var container = document.getElementById('toast-container'); if (!container) return; var toast = document.createElement('div'); toast.className = 'toast toast-' + (type || 'info'); toast.textContent = message; container.appendChild(toast); setTimeout(function() { toast.classList.add('fade-out'); setTimeout(function() { if (toast.parentNode) { toast.parentNode.removeChild(toast); } }, 300); }, 5000); } function updateFPS() { state.frameCount++; const now = performance.now(); const elapsed = now - state.lastFpsTime; if (elapsed >= 1000) { const fps = Math.round(state.frameCount * 1000 / elapsed); document.getElementById('fps-counter').textContent = fps; state.frameCount = 0; state.lastFpsTime = now; } } // ============================================ // WebSocket Connection // ============================================ function connectWebSocket() { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsURL = `${wsProtocol}//${window.location.host}/ws/dashboard`; console.log('[Spaxel] Connecting to', wsURL); state.ws = new WebSocket(wsURL); state.ws.binaryType = 'arraybuffer'; state.ws.onopen = function() { console.log('[Spaxel] WebSocket connected'); state.wsConnected = true; updateConnectionStatus(true); }; state.ws.onclose = function(event) { console.log('[Spaxel] WebSocket closed:', event.code, event.reason); state.wsConnected = false; updateConnectionStatus(false); scheduleReconnect(); }; state.ws.onerror = function(error) { console.error('[Spaxel] WebSocket error:', error); }; state.ws.onmessage = function(event) { handleMessage(event.data); }; } function scheduleReconnect() { console.log('[Spaxel] Reconnecting in', CONFIG.wsReconnectDelay, 'ms'); setTimeout(connectWebSocket, CONFIG.wsReconnectDelay); } function updateConnectionStatus(connected) { const dot = document.getElementById('ws-status'); const text = document.getElementById('ws-status-text'); if (connected) { dot.classList.remove('disconnected'); dot.classList.add('connected'); text.textContent = 'Connected'; } else { dot.classList.remove('connected'); dot.classList.add('disconnected'); text.textContent = 'Disconnected'; } } // ============================================ // Message Handling // ============================================ function handleMessage(data) { if (typeof data === 'string') { // JSON message try { const msg = JSON.parse(data); handleJSONMessage(msg); } catch (e) { console.error('[Spaxel] Failed to parse JSON:', e); } } else if (data instanceof ArrayBuffer) { // Binary CSI frame handleBinaryFrame(data); } } function handleJSONMessage(msg) { switch (msg.type) { case 'state': // Initial state dump if (msg.nodes) { msg.nodes.forEach(node => { state.nodes.set(node.mac, { mac: node.mac, firmware: node.firmware_version, chip: node.chip, lastSeen: Date.now() }); }); } if (msg.links) { msg.links.forEach(link => { const existing = state.links.get(link.id) || {}; state.links.set(link.id, { nodeMAC: link.node_mac, peerMAC: link.peer_mac, lastFrame: Date.now(), lastCSI: existing.lastCSI || null, motionDetected: existing.motionDetected || false, deltaRMS: existing.deltaRMS || 0, ampHistory: existing.ampHistory || [], lastAmpSample: existing.lastAmpSample || 0 }); }); } if (msg.motion_states) { msg.motion_states.forEach(ms => applyMotionState(ms)); } updateNodeList(); updateLinkList(); Viz3D.applyLinks(msg.links || []); break; case 'node_connected': state.nodes.set(msg.mac, { mac: msg.mac, firmware: msg.firmware_version, chip: msg.chip, lastSeen: Date.now() }); updateNodeList(); if (window.SpaxelTroubleshoot) { window.SpaxelTroubleshoot.handleEvent('node_connected', msg); } // Show first-time tooltips on first node connection if (window.SpaxelTooltips && state.nodes.size === 1) { setTimeout(function () { window.SpaxelTooltips.showSequence(); }, 2000); } break; case 'node_disconnected': state.nodes.delete(msg.mac); updateNodeList(); if (window.SpaxelTroubleshoot) { window.SpaxelTroubleshoot.handleEvent('node_disconnected', msg); } break; case 'link_active': if (!state.links.has(msg.id)) { state.links.set(msg.id, { nodeMAC: msg.node_mac, peerMAC: msg.peer_mac, lastFrame: Date.now(), lastCSI: null, motionDetected: false, deltaRMS: 0, ampHistory: [], lastAmpSample: 0 }); updateLinkList(); } Viz3D.handleLinkActive(msg); break; case 'link_inactive': state.links.delete(msg.id); updateLinkList(); Viz3D.handleLinkInactive(msg); break; case 'motion_state': // Targeted broadcast on state change if (msg.links) { let changed = false; msg.links.forEach(ms => { if (applyMotionState(ms)) changed = true; }); if (changed) updateLinkList(); } break; case 'presence_update': handlePresenceUpdate(msg); break; case 'registry_state': Viz3D.handleRegistryState(msg); if (window.Placement) Placement.handleRegistryState(msg); // Merge virtual nodes into local state and refresh node list if (msg.nodes) { var registryMACs = new Set(msg.nodes.map(function (n) { return n.mac; })); msg.nodes.forEach(function (node) { if (!state.nodes.has(node.mac)) { state.nodes.set(node.mac, { mac: node.mac, firmware: '', chip: '', lastSeen: 0, virtual: !!node.virtual }); } else { var existing = state.nodes.get(node.mac); if (node.virtual) existing.virtual = true; } }); // Remove virtual nodes no longer in registry state.nodes.forEach(function (node, mac) { if (!registryMACs.has(mac) && node.virtual) { state.nodes.delete(mac); } }); updateNodeList(); } break; case 'loc_update': Viz3D.handleLocUpdate(msg); break; case 'link_health': // Health score update from server if (msg.links) { handleLinkHealthUpdate(msg.links); } break; case 'diurnal_ready': // Diurnal patterns learned notification showToast(msg.message || 'Daily patterns learned! Detection accuracy improved.', 'success'); // Refresh diurnal status fetchDiurnalStatus(); break; default: // Ignore unknown types (forward-compatible) } } // applyMotionState updates a link's motion fields; returns true if it changed. function applyMotionState(ms) { const link = state.links.get(ms.link_id); if (!link) return false; const prev = link.motionDetected; link.motionDetected = ms.motion_detected; link.deltaRMS = ms.delta_rms || 0; // Phase 6: Breathing/dwell state link.breathingState = ms.breathing_state || 'CLEAR'; link.breathingBPM = ms.breathing_bpm || 0; return prev !== ms.motion_detected; } function handleBinaryFrame(buffer) { const frame = parseCSIFrame(buffer); if (!frame) return; const linkID = frame.linkID; // Update link state let link = state.links.get(linkID); if (!link) { link = { nodeMAC: frame.nodeMAC, peerMAC: frame.peerMAC, lastFrame: Date.now(), lastCSI: null, motionDetected: false, deltaRMS: 0, ampHistory: [], lastAmpSample: 0 }; state.links.set(linkID, link); updateLinkList(); } else { link.lastFrame = Date.now(); // Ensure time-series fields exist on links pre-created from JSON events if (!link.ampHistory) { link.ampHistory = []; link.lastAmpSample = 0; } } // Store CSI for chart rendering link.lastCSI = frame; // Push amplitude sample to time series (rate-limited) const now = performance.now(); if (now - link.lastAmpSample >= CONFIG.tsMinIntervalMs) { link.lastAmpSample = now; let sum = 0; for (let i = 0; i < frame.subcarriers.length; i++) { sum += frame.subcarriers[i].amplitude; } const meanAmp = frame.subcarriers.length > 0 ? sum / frame.subcarriers.length : 0; link.ampHistory.push({ t: now, amp: meanAmp, motion: link.motionDetected }); if (link.ampHistory.length > CONFIG.tsMaxPoints) { link.ampHistory.shift(); } } // Update charts if this is the selected link if (state.selectedLinkID === linkID) { if (now - state.lastChartUpdate >= CONFIG.chartUpdateMs) { drawAmplitudeChart(frame); drawTimeSeries(link.ampHistory); state.lastChartUpdate = now; } } } // ============================================ // CSI Frame Parsing (matches Go binary format) // ============================================ function parseCSIFrame(buffer) { const view = new DataView(buffer); const bytes = new Uint8Array(buffer); if (bytes.length < 24) { return null; // Header too short } const nodeMAC = formatMAC(bytes, 0); const peerMAC = formatMAC(bytes, 6); const timestampUS = view.getBigUint64(12, true); // little-endian const rssi = view.getInt8(20); const noiseFloor = view.getInt8(21); const channel = bytes[22]; const nSub = bytes[23]; if (channel === 0 || channel > 14) { return null; // Invalid channel } const expectedLen = 24 + nSub * 2; if (bytes.length !== expectedLen) { return null; // Payload length mismatch } // Extract I/Q pairs and compute amplitude const subcarriers = []; for (let i = 0; i < nSub; i++) { const offset = 24 + i * 2; const iVal = bytes[offset]; const qVal = bytes[offset + 1]; // Convert from unsigned to signed (JavaScript quirk) const I = iVal > 127 ? iVal - 256 : iVal; const Q = qVal > 127 ? qVal - 256 : qVal; const amplitude = Math.sqrt(I * I + Q * Q); subcarriers.push({ I, Q, amplitude }); } return { nodeMAC, peerMAC, linkID: `${nodeMAC}:${peerMAC}`, timestampUS: Number(timestampUS), rssi, noiseFloor, channel, nSub, subcarriers }; } function formatMAC(bytes, offset) { const parts = []; for (let i = 0; i < 6; i++) { parts.push(bytes[offset + i].toString(16).padStart(2, '0').toUpperCase()); } return parts.join(':'); } // ============================================ // UI Updates // ============================================ function updateNodeList() { const container = document.getElementById('node-list'); document.getElementById('node-count').textContent = state.nodes.size; if (state.nodes.size === 0) { container.innerHTML = '