diff --git a/dashboard/js/accuracy.js b/dashboard/js/accuracy.js index f059032..d70a4c1 100644 --- a/dashboard/js/accuracy.js +++ b/dashboard/js/accuracy.js @@ -172,6 +172,7 @@ this.fetchHistory(); this.fetchImprovement(); this.fetchStats(); + this.fetchZoneBreakdown(); }, /** @@ -237,6 +238,85 @@ }); }, + /** + * Fetch per-zone accuracy breakdown + */ + fetchZoneBreakdown: function() { + var self = this; + var week = this.getCurrentWeek(); + + fetch('/api/learning/accuracy/history?scope_type=zone&weeks=1') + .then(function(res) { return res.json(); }) + .then(function(data) { + self.renderZoneBreakdown(data); + }) + .catch(function(err) { + console.error('[Accuracy] Failed to fetch zone breakdown:', err); + self.renderZoneBreakdown([]); + }); + }, + + /** + * Get current week string + */ + getCurrentWeek: function() { + var now = new Date(); + var start = new Date(now.getFullYear(), 0, 1); + var diff = now - start; + var oneWeek = 604800000; // ms in a week + var weekNum = Math.ceil((diff + start.getDay() * 86400000) / oneWeek); + return now.getFullYear() + '-W' + (weekNum < 10 ? '0' : '') + weekNum; + }, + + /** + * Render zone breakdown + */ + renderZoneBreakdown: function(zones) { + var container = document.getElementById('zone-breakdown'); + if (!container) return; + + if (!zones || zones.length === 0) { + container.innerHTML = '
No zone data yet
'; + return; + } + + var self = this; + var html = ''; + zones.sort(function(a, b) { return (b.f1 || 0) - (a.f1 || 0); }); + + zones.forEach(function(zone) { + var f1 = zone.f1 !== null ? (zone.f1 * 100).toFixed(0) + '%' : '--'; + var color = zone.f1 >= 0.8 ? '#66bb6a' : (zone.f1 >= 0.6 ? '#ffa726' : '#ef5350'); + + html += '
' + + '' + self.formatZoneName(zone.scope_id) + '' + + '' + f1 + '' + + '
'; + }); + + container.innerHTML = html; + + // Add click handlers to focus on zone + container.querySelectorAll('.zone-item').forEach(function(item) { + item.onclick = function() { + var zoneId = this.getAttribute('data-zone-id'); + if (window.Viz3D && window.Viz3D.focusOnZone) { + window.Viz3D.focusOnZone(zoneId); + } + }; + }); + }, + + /** + * Format zone name for display + */ + formatZoneName: function(zoneId) { + if (!zoneId) return 'Unknown'; + // Convert zone-xxx to Xxx + return zoneId.replace(/^zone-/, '').replace(/-/g, ' ') + .replace(/\b\w/g, function(c) { return c.toUpperCase(); }); + }, + /** * Update display with current data */ @@ -569,6 +649,11 @@ padding: 4px 8px;\ background: rgba(255, 255, 255, 0.03);\ border-radius: 3px;\ + cursor: pointer;\ + transition: background 0.2s;\ + }\ + .zone-item:hover {\ + background: rgba(255, 255, 255, 0.08);\ }\ .zone-name {\ color: #bbb;\ @@ -576,6 +661,12 @@ .zone-score {\ font-weight: 500;\ }\ + .no-data-text {\ + color: #666;\ + font-size: 11px;\ + text-align: center;\ + padding: 8px;\ + }\ .accuracy-stats {\ font-size: 11px;\ }\ diff --git a/dashboard/js/viz3d.js b/dashboard/js/viz3d.js index da08e1a..663bbc6 100644 --- a/dashboard/js/viz3d.js +++ b/dashboard/js/viz3d.js @@ -10,7 +10,7 @@ const Viz3D = (function () { 'use strict'; // ── module state ────────────────────────────────────────────────────────── - let _scene, _camera, _controls, _clock; + let _scene, _camera, _controls, _clock, _renderer; let _room = null; let _roomObjs = { floor: null, ceiling: null, walls: [], edges: null }; let _nodeMeshes = new Map(); // mac → THREE.Mesh @@ -22,6 +22,13 @@ const Viz3D = (function () { let _floorTex = null; let _followId = null; + // ── blob interaction state ──────────────────────────────────────────────── + let _raycaster = new THREE.Raycaster(); + let _mouse = new THREE.Vector2(); + let _hoveredBlob = null; + let _feedbackTooltip = null; + let _renderer = null; + // Ghost node for repositioning advice let _ghostNode = null; // THREE.Mesh (translucent) let _ghostLine = null; // THREE.Line (dashed, from original to ghost) @@ -32,11 +39,17 @@ const Viz3D = (function () { // ── init / tick ─────────────────────────────────────────────────────────── - function init(scene, camera, controls) { + function init(scene, camera, controls, renderer) { _scene = scene; _camera = camera; _controls = controls; _clock = new THREE.Clock(); + + // Initialize blob interaction if renderer provided + if (renderer) { + initBlobInteraction(renderer); + _addBlobFeedbackStyles(); + } } function update() { @@ -55,6 +68,9 @@ const Viz3D = (function () { // Update ghost line if node moved _updateGhostLine(); + + // Update flow arrow animation + updateFlowAnimation(dt); } // ── room bounds ─────────────────────────────────────────────────────────── @@ -517,6 +533,7 @@ const Viz3D = (function () { const color = BLOB_COLORS[ci]; const group = new THREE.Group(); + group.userData.blobId = id; // Store blob ID for interaction _scene.add(group); const humanoid = _buildHumanoid(color); @@ -544,7 +561,7 @@ const Viz3D = (function () { ); _scene.add(pillar); - return { group, humanoid, trail, pillar }; + return { group, humanoid, trail, pillar, blobId: id }; } function _removeBlobObj(id, obj) { @@ -579,12 +596,18 @@ const Viz3D = (function () { function applyLocUpdate(blobs) { const seen = new Set(); + const now = Date.now(); blobs.forEach(b => { seen.add(b.id); let obj = _blobs3D.get(b.id); - if (!obj) { obj = _createBlobObj(b.id); _blobs3D.set(b.id, obj); } + if (!obj) { + obj = _createBlobObj(b.id); + obj.createdAt = now; + _blobs3D.set(b.id, obj); + } obj.group.position.set(b.x, 0, b.z); + obj.lastPosition = { x: b.x, z: b.z }; const speed = Math.sqrt(b.vx*b.vx + b.vz*b.vz); _setPosture(obj.humanoid, speed > 0.25 ? 'walking' : 'standing'); @@ -602,6 +625,265 @@ const Viz3D = (function () { }); } + // ── blob interaction (feedback buttons) ──────────────────────────────────── + + /** + * Initialize blob interaction system. + * @param {THREE.WebGLRenderer} renderer - The Three.js renderer + */ + function initBlobInteraction(renderer) { + _renderer = renderer; + + // Create feedback tooltip element + _feedbackTooltip = document.createElement('div'); + _feedbackTooltip.className = 'blob-feedback-tooltip'; + _feedbackTooltip.style.display = 'none'; + document.body.appendChild(_feedbackTooltip); + + // Add mouse move listener + var canvas = renderer.domElement; + canvas.addEventListener('mousemove', _onBlobMouseMove); + canvas.addEventListener('mouseleave', _hideBlobFeedbackTooltip); + } + + /** + * Handle mouse move for blob hover detection. + */ + function _onBlobMouseMove(event) { + if (!_camera || !_scene || _blobs3D.size === 0) return; + + // Calculate mouse position in normalized device coordinates + var rect = event.target.getBoundingClientRect(); + _mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + _mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + + // Raycast to find hovered blob + _raycaster.setFromCamera(_mouse, _camera); + + var blobMeshes = []; + _blobs3D.forEach(function(obj) { + if (obj.group) { + blobMeshes.push(obj.group); + } + }); + + var intersects = _raycaster.intersectObjects(blobMeshes, true); + + if (intersects.length > 0) { + // Find the blob object from the intersected mesh + var intersected = intersects[0].object; + var blobObj = null; + + // Walk up the parent chain to find the group + var current = intersected; + while (current) { + _blobs3D.forEach(function(obj, id) { + if (obj.group === current) { + blobObj = obj; + } + }); + if (blobObj) break; + current = current.parent; + } + + if (blobObj && blobObj !== _hoveredBlob) { + _hoveredBlob = blobObj; + _showBlobFeedbackTooltip(event, blobObj); + } else if (blobObj) { + // Update tooltip position + _updateTooltipPosition(event); + } + } else { + if (_hoveredBlob) { + _hideBlobFeedbackTooltip(); + } + } + } + + /** + * Show feedback tooltip for a blob. + */ + function _showBlobFeedbackTooltip(event, blobObj) { + if (!_feedbackTooltip) return; + + var blobId = blobObj.blobId; + var eventType = 'blob_detection'; + var eventTime = blobObj.createdAt || Date.now(); + var position = blobObj.lastPosition || { x: 0, z: 0 }; + + _feedbackTooltip.innerHTML = + '
' + + '
Track #' + blobId + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
'; + + _feedbackTooltip.style.display = 'block'; + _updateTooltipPosition(event); + + // Store blob data for feedback submission + _feedbackTooltip.dataset.blobId = blobId; + _feedbackTooltip.dataset.eventType = eventType; + _feedbackTooltip.dataset.eventTime = eventTime; + _feedbackTooltip.dataset.posX = position.x; + _feedbackTooltip.dataset.posZ = position.z; + } + + /** + * Update tooltip position to follow cursor. + */ + function _updateTooltipPosition(event) { + if (!_feedbackTooltip) return; + + var offsetX = 15; + var offsetY = 15; + + _feedbackTooltip.style.left = (event.clientX + offsetX) + 'px'; + _feedbackTooltip.style.top = (event.clientY + offsetY) + 'px'; + } + + /** + * Hide the blob feedback tooltip. + */ + function _hideBlobFeedbackTooltip() { + if (_feedbackTooltip) { + _feedbackTooltip.style.display = 'none'; + } + _hoveredBlob = null; + } + + /** + * Submit feedback for a blob detection. + * @param {number} blobId - The blob ID + * @param {string} feedbackType - Feedback type (TRUE_POSITIVE, FALSE_POSITIVE, etc.) + */ + function submitBlobFeedback(blobId, feedbackType) { + var blobObj = _blobs3D.get(blobId); + if (!blobObj) return; + + var details = { + position_x: blobObj.lastPosition ? blobObj.lastPosition.x : 0, + position_z: blobObj.lastPosition ? blobObj.lastPosition.z : 0 + }; + + // Use Feedback module if available + if (window.Feedback) { + window.Feedback.sendFeedback( + 'blob-' + blobId + '-' + (blobObj.createdAt || Date.now()), + window.Feedback.EventTypes.BLOB_DETECTION, + feedbackType, + details + ); + } else { + // Direct API call + fetch('/api/learning/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + event_id: 'blob-' + blobId + '-' + (blobObj.createdAt || Date.now()), + event_type: 'blob_detection', + feedback_type: feedbackType, + details: details + }) + }).then(function(res) { return res.json(); }) + .then(function(result) { + console.log('[Viz3D] Feedback submitted:', feedbackType); + }) + .catch(function(err) { + console.error('[Viz3D] Failed to submit feedback:', err); + }); + } + + _hideBlobFeedbackTooltip(); + } + + /** + * Show the feedback form for a blob (thumbs-down flow). + * @param {number} blobId - The blob ID + */ + function showBlobFeedbackForm(blobId) { + var blobObj = _blobs3D.get(blobId); + if (!blobObj) return; + + var details = { + position_x: blobObj.lastPosition ? blobObj.lastPosition.x : 0, + position_z: blobObj.lastPosition ? blobObj.lastPosition.z : 0 + }; + + if (window.Feedback) { + window.Feedback.showFeedbackPanel( + 'blob-' + blobId + '-' + (blobObj.createdAt || Date.now()), + window.Feedback.EventTypes.BLOB_DETECTION, + blobObj.createdAt || Date.now(), + details + ); + } + + _hideBlobFeedbackTooltip(); + } + + /** + * Add blob feedback tooltip styles. + */ + function _addBlobFeedbackStyles() { + if (document.getElementById('blob-feedback-styles')) return; + + var style = document.createElement('style'); + style.id = 'blob-feedback-styles'; + style.textContent = + '.blob-feedback-tooltip {' + + ' position: fixed;' + + ' background: rgba(0, 0, 0, 0.9);' + + ' border-radius: 6px;' + + ' padding: 8px 12px;' + + ' z-index: 1000;' + + ' pointer-events: auto;' + + ' box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);' + + '}' + + '.feedback-tooltip-content {' + + ' display: flex;' + + ' flex-direction: column;' + + ' gap: 6px;' + + '}' + + '.feedback-tooltip-label {' + + ' font-size: 11px;' + + ' color: #888;' + + ' text-align: center;' + + '}' + + '.feedback-tooltip-actions {' + + ' display: flex;' + + ' gap: 6px;' + + ' justify-content: center;' + + '}' + + '.feedback-btn-icon {' + + ' background: rgba(255, 255, 255, 0.1);' + + ' border: none;' + + ' width: 32px;' + + ' height: 32px;' + + ' border-radius: 50%;' + + ' cursor: pointer;' + + ' font-size: 16px;' + + ' display: flex;' + + ' align-items: center;' + + ' justify-content: center;' + + ' transition: background 0.2s;' + + '}' + + '.feedback-btn-icon:hover {' + + ' background: rgba(255, 255, 255, 0.2);' + + '}' + + '.feedback-thumbs-up:hover {' + + ' background: rgba(76, 175, 80, 0.4);' + + '}' + + '.feedback-thumbs-down:hover {' + + ' background: rgba(244, 67, 54, 0.4);' + + '}'; + document.head.appendChild(style); + } + // ── message handlers ────────────────────────────────────────────────────── function handleRegistryState(msg) { @@ -752,6 +1034,348 @@ const Viz3D = (function () { _ghostLine.computeLineDistances(); } + // ── Flow Analytics Layers ──────────────────────────────────────────────────── + + // State for analytics layers + let _flowLayerVisible = false; + let _dwellLayerVisible = false; + let _corridorLayerVisible = false; + let _flowArrows = []; // Array of THREE.ArrowHelper + let _dwellPlanes = []; // Array of THREE.Mesh (heatmap cells) + let _corridorMeshes = []; // Array of THREE.Mesh (corridor regions) + let _flowAnimTime = 0; + let _flowData = null; + let _dwellData = null; + let _corridorData = null; + let _flowPersonFilter = ''; + let _flowTimeFilter = '30d'; // '7d', '30d', 'all' + + /** + * Set visibility of flow arrows layer. + * @param {boolean} visible + */ + function setFlowLayerVisible(visible) { + _flowLayerVisible = visible; + _flowArrows.forEach(function(arrow) { + arrow.visible = visible; + }); + if (visible && !_flowData) { + fetchFlowData(); + } + } + + /** + * Set visibility of dwell heatmap layer. + * @param {boolean} visible + */ + function setDwellLayerVisible(visible) { + _dwellLayerVisible = visible; + _dwellPlanes.forEach(function(plane) { + plane.visible = visible; + }); + if (visible && !_dwellData) { + fetchDwellData(); + } + } + + /** + * Set visibility of corridor overlay layer. + * @param {boolean} visible + */ + function setCorridorLayerVisible(visible) { + _corridorLayerVisible = visible; + _corridorMeshes.forEach(function(mesh) { + mesh.visible = visible; + }); + if (visible && !_corridorData) { + fetchCorridorData(); + } + } + + /** + * Set person filter for flow/dwell data. + * @param {string} personId - Empty string for all people + */ + function setFlowPersonFilter(personId) { + if (_flowPersonFilter !== personId) { + _flowPersonFilter = personId; + _flowData = null; + _dwellData = null; + if (_flowLayerVisible) fetchFlowData(); + if (_dwellLayerVisible) fetchDwellData(); + } + } + + /** + * Set time filter for flow data. + * @param {string} timeFilter - '7d', '30d', or 'all' + */ + function setFlowTimeFilter(timeFilter) { + if (_flowTimeFilter !== timeFilter) { + _flowTimeFilter = timeFilter; + _flowData = null; + if (_flowLayerVisible) fetchFlowData(); + } + } + + /** + * Fetch flow data from API and update visualization. + */ + function fetchFlowData() { + var since = 0; + var now = Date.now() / 1000; + if (_flowTimeFilter === '7d') { + since = now - 7 * 24 * 3600; + } else if (_flowTimeFilter === '30d') { + since = now - 30 * 24 * 3600; + } + + var url = '/api/analytics/flow?since=' + since + '&until=' + now; + if (_flowPersonFilter) { + url += '&person_id=' + encodeURIComponent(_flowPersonFilter); + } + + fetch(url) + .then(function(response) { return response.json(); }) + .then(function(data) { + _flowData = data; + rebuildFlowArrows(); + }) + .catch(function(err) { + console.error('[Viz3D] Failed to fetch flow data:', err); + }); + } + + /** + * Fetch dwell heatmap data from API and update visualization. + */ + function fetchDwellData() { + var url = '/api/analytics/dwell'; + if (_flowPersonFilter) { + url += '?person_id=' + encodeURIComponent(_flowPersonFilter); + } + + fetch(url) + .then(function(response) { return response.json(); }) + .then(function(data) { + _dwellData = data; + rebuildDwellPlanes(); + }) + .catch(function(err) { + console.error('[Viz3D] Failed to fetch dwell data:', err); + }); + } + + /** + * Fetch corridor data from API and update visualization. + */ + function fetchCorridorData() { + fetch('/api/analytics/corridors') + .then(function(response) { return response.json(); }) + .then(function(data) { + _corridorData = data; + rebuildCorridorMeshes(); + }) + .catch(function(err) { + console.error('[Viz3D] Failed to fetch corridor data:', err); + }); + } + + /** + * Rebuild flow arrow meshes from _flowData. + */ + function rebuildFlowArrows() { + // Clear existing arrows + _flowArrows.forEach(function(arrow) { + _scene.remove(arrow); + }); + _flowArrows = []; + + if (!_flowData || !_flowData.cells) return; + + var gridSize = _flowData.grid_size || 0.25; + + _flowData.cells.forEach(function(cell) { + var cx = (cell.grid_x + 0.5) * gridSize; + var cz = (cell.grid_z + 0.5) * gridSize; + + // Direction vector + var dir = new THREE.Vector3(cell.vector_x, 0, cell.vector_z).normalize(); + var length = Math.min(Math.sqrt(cell.vector_x * cell.vector_x + cell.vector_z * cell.vector_z) * 0.5 + 0.1, 0.4); + + // Color based on segment count (blue to red) + var intensity = Math.min(cell.segment_count / 50, 1); + var color = new THREE.Color(); + color.setHSL(0.6 - intensity * 0.6, 0.8, 0.5); // Blue (0.6) to Red (0) + + var arrow = new THREE.ArrowHelper( + dir, + new THREE.Vector3(cx, 0.02, cz), + length, + color.getHex(), + length * 0.3, // headLength + length * 0.15 // headWidth + ); + arrow.visible = _flowLayerVisible; + arrow.userData = { segmentCount: cell.segment_count, baseOpacity: 0.7 }; + + _scene.add(arrow); + _flowArrows.push(arrow); + }); + + console.log('[Viz3D] Built', _flowArrows.length, 'flow arrows'); + } + + /** + * Rebuild dwell heatmap planes from _dwellData. + */ + function rebuildDwellPlanes() { + // Clear existing planes + _dwellPlanes.forEach(function(plane) { + _scene.remove(plane); + plane.geometry.dispose(); + plane.material.dispose(); + }); + _dwellPlanes = []; + + if (!_dwellData || !_dwellData.cells) return; + + var gridSize = 0.25; // GridCellSize + + _dwellData.cells.forEach(function(cell) { + var cx = (cell.grid_x + 0.5) * gridSize; + var cz = (cell.grid_z + 0.5) * gridSize; + + // Color: blue (low) -> green (mid) -> red (high) + var normalized = cell.normalized; + var color = new THREE.Color(); + if (normalized < 0.5) { + // Blue to green + color.setHSL(0.55 + normalized * 0.1, 0.8, 0.4); + } else { + // Green to red + color.setHSL(0.35 - (normalized - 0.5) * 0.7, 0.8, 0.45); + } + + var geo = new THREE.PlaneGeometry(gridSize * 0.95, gridSize * 0.95); + var mat = new THREE.MeshBasicMaterial({ + color: color.getHex(), + transparent: true, + opacity: 0.4, + side: THREE.DoubleSide + }); + + var plane = new THREE.Mesh(geo, mat); + plane.rotation.x = -Math.PI / 2; + plane.position.set(cx, 0.015, cz); + plane.visible = _dwellLayerVisible; + plane.userData = { count: cell.count, normalized: normalized }; + + _scene.add(plane); + _dwellPlanes.push(plane); + }); + + console.log('[Viz3D] Built', _dwellPlanes.length, 'dwell heatmap cells'); + } + + /** + * Rebuild corridor region meshes from _corridorData. + */ + function rebuildCorridorMeshes() { + // Clear existing meshes + _corridorMeshes.forEach(function(mesh) { + _scene.remove(mesh); + mesh.geometry.dispose(); + mesh.material.dispose(); + }); + _corridorMeshes = []; + + if (!_corridorData || !Array.isArray(_corridorData)) return; + + _corridorData.forEach(function(corridor) { + // Create an extruded rectangle for the corridor region + var length = corridor.length_m; + var width = corridor.width_m; + var cx = corridor.centroid_x; + var cz = corridor.centroid_z; + + // Compute rotation from dominant direction + var angle = Math.atan2(corridor.dominant_dir_x, corridor.dominant_dir_z); + + var geo = new THREE.PlaneGeometry(length, width); + var mat = new THREE.MeshBasicMaterial({ + color: 0x8899aa, // Warm grey + transparent: true, + opacity: 0.3, + side: THREE.DoubleSide + }); + + var mesh = new THREE.Mesh(geo, mat); + mesh.rotation.x = -Math.PI / 2; + mesh.rotation.z = angle; + mesh.position.set(cx, 0.025, cz); // Slightly raised + mesh.visible = _corridorLayerVisible; + mesh.userData = { corridor: corridor }; + + _scene.add(mesh); + _corridorMeshes.push(mesh); + }); + + console.log('[Viz3D] Built', _corridorMeshes.length, 'corridor regions'); + } + + /** + * Update flow arrow animation (called from main update loop). + * @param {number} dt - Delta time in seconds + */ + function updateFlowAnimation(dt) { + if (!_flowLayerVisible) return; + + _flowAnimTime += dt; + // 2-second loop for flowing effect + var phase = (_flowAnimTime % 2.0) / 2.0; + + _flowArrows.forEach(function(arrow, index) { + // Stagger animation based on arrow position + var stagger = (arrow.position.x * 0.5 + arrow.position.z * 0.3) % 1.0; + var localPhase = (phase + stagger) % 1.0; + + // Animate opacity: 0.3 -> 1.0 -> 0.3 + var opacity = 0.3 + 0.7 * (1 - Math.abs(localPhase - 0.5) * 2); + + if (arrow.line && arrow.line.material) { + arrow.line.material.opacity = opacity; + arrow.line.material.transparent = true; + } + if (arrow.cone && arrow.cone.material) { + arrow.cone.material.opacity = opacity; + arrow.cone.material.transparent = true; + } + }); + } + + /** + * Refresh all analytics data. + */ + function refreshAnalyticsData() { + if (_flowLayerVisible) fetchFlowData(); + if (_dwellLayerVisible) fetchDwellData(); + if (_corridorLayerVisible) fetchCorridorData(); + } + + /** + * Get current analytics layer visibility state. + */ + function getAnalyticsLayerState() { + return { + flow: _flowLayerVisible, + dwell: _dwellLayerVisible, + corridor: _corridorLayerVisible, + personFilter: _flowPersonFilter, + timeFilter: _flowTimeFilter + }; + } + // ── public API ──────────────────────────────────────────────────────────── return { init, @@ -772,5 +1396,17 @@ const Viz3D = (function () { updateLinkHealth: updateLinkHealth, getLinkHealth: getLinkHealth, getAllLinkHealth: getAllLinkHealth, + // Analytics layers API + setFlowLayerVisible: setFlowLayerVisible, + setDwellLayerVisible: setDwellLayerVisible, + setCorridorLayerVisible: setCorridorLayerVisible, + setFlowPersonFilter: setFlowPersonFilter, + setFlowTimeFilter: setFlowTimeFilter, + refreshAnalyticsData: refreshAnalyticsData, + getAnalyticsLayerState: getAnalyticsLayerState, + // Blob feedback API + initBlobInteraction: initBlobInteraction, + submitBlobFeedback: submitBlobFeedback, + showBlobFeedbackForm: showBlobFeedbackForm, }; })(); diff --git a/mothership/cmd/mothership/main_phase6.go b/mothership/cmd/mothership/main_phase6.go index 86944c5..429cefc 100644 --- a/mothership/cmd/mothership/main_phase6.go +++ b/mothership/cmd/mothership/main_phase6.go @@ -24,10 +24,12 @@ import ( "github.com/spaxel/mothership/internal/ble" "github.com/spaxel/mothership/internal/dashboard" "github.com/spaxel/mothership/internal/diagnostics" + "github.com/spaxel/mothership/internal/events" "github.com/spaxel/mothership/internal/falldetect" "github.com/spaxel/mothership/internal/fleet" "github.com/spaxel/mothership/internal/ingestion" "github.com/spaxel/mothership/internal/learning" + "github.com/spaxel/mothership/internal/localization" "github.com/spaxel/mothership/internal/mqtt" "github.com/spaxel/mothership/internal/notify" "github.com/spaxel/mothership/internal/ota" @@ -35,6 +37,7 @@ import ( "github.com/spaxel/mothership/internal/provisioning" "github.com/spaxel/mothership/internal/recorder" "github.com/spaxel/mothership/internal/replay" + "github.com/spaxel/mothership/internal/sleep" "github.com/spaxel/mothership/internal/zones" sigproc "github.com/spaxel/mothership/internal/signal" ) @@ -170,6 +173,23 @@ func main() { log.Printf("[INFO] Flow analytics at %s", filepath.Join(cfg.DataDir, "analytics.db")) } + // Phase 6: Anomaly detector for security mode + var anomalyDetector *analytics.Detector + anomalyDetector, err = analytics.NewDetector( + filepath.Join(cfg.DataDir, "anomaly.db"), + analytics.DefaultAnomalyScoreConfig(), + ) + if err != nil { + log.Printf("[WARN] Failed to open anomaly detector: %v", err) + } else { + defer anomalyDetector.Close() + log.Printf("[INFO] Anomaly detector at %s (learning period: 7 days)", filepath.Join(cfg.DataDir, "anomaly.db")) + + // Start periodic model updates (every 6 hours) + anomalyDetector.RunPeriodicUpdate(ctx, 6*time.Hour) + // Note: Providers will be wired after dashboardHub and notifyService are created + } + // Phase 6: Automation engine automationEngine, err := automation.NewEngine(filepath.Join(cfg.DataDir, "automation.db")) if err != nil { @@ -183,10 +203,51 @@ func main() { fallDetector := falldetect.NewDetector() log.Printf("[INFO] Fall detector initialized") + // Phase 6: Sleep quality monitor + sleepMonitor := sleep.NewMonitor(sleep.MonitorConfig{ + SampleInterval: 30 * time.Second, + ReportHour: 7, // Generate reports at 7 AM + SleepStartHour: 22, // 10 PM + SleepEndHour: 7, // 7 AM + }) + sleepMonitor.SetProcessorManager(pm) + sleepMonitor.SetReportCallback(func(linkID string, report *sleep.SleepReport) { + // Broadcast sleep report to dashboard + msg := map[string]interface{}{ + "type": "sleep_report", + "link_id": linkID, + "session_date": report.SessionDate.Format("2006-01-02"), + "overall_score": report.Metrics.OverallScore, + "quality_rating": report.Metrics.QualityRating, + "generated_at": report.GeneratedAt.Unix(), + } + data, _ := json.Marshal(msg) + dashboardHub.Broadcast(data) + + // Send notification for morning report + if notifyService != nil { + notif := notify.Notification{ + Title: "Sleep Report", + Body: fmt.Sprintf("Sleep quality: %s (%.0f/100)", report.Metrics.QualityRating, report.Metrics.OverallScore), + Priority: 2, + Tags: []string{"sleep", "morning"}, + Data: report.ToJSONMap(), + } + notifyService.Send(notif) + } + + log.Printf("[INFO] Sleep report for %s: score=%.1f rating=%s", linkID, report.Metrics.OverallScore, report.Metrics.QualityRating) + }) + sleepMonitor.Start() + defer sleepMonitor.Stop() + log.Printf("[INFO] Sleep quality monitor started (window: 22:00-07:00, report at 07:00)") + // Phase 6: Prediction module for presence prediction var predictionStore *prediction.ModelStore var predictionHistory *prediction.HistoryUpdater var predictionPredictor *prediction.Predictor + var predictionAccuracy *prediction.AccuracyTracker + var predictionHorizon *prediction.HorizonPredictor predictionStore, err = prediction.NewModelStore(filepath.Join(cfg.DataDir, "prediction.db")) if err != nil { log.Printf("[WARN] Failed to open prediction store: %v", err) @@ -202,9 +263,26 @@ func main() { log.Printf("[WARN] Failed to load stored prediction positions: %v", err) } + // Create accuracy tracker + predictionAccuracy, err = prediction.NewAccuracyTracker(filepath.Join(cfg.DataDir, "prediction_accuracy.db")) + if err != nil { + log.Printf("[WARN] Failed to open accuracy tracker: %v", err) + } else { + defer predictionAccuracy.Close() + log.Printf("[INFO] Prediction accuracy tracker at %s", filepath.Join(cfg.DataDir, "prediction_accuracy.db")) + } + // Create predictor predictionPredictor = prediction.NewPredictor(predictionStore) + // Create horizon predictor with Monte Carlo simulation + if predictionAccuracy != nil { + predictionHorizon = prediction.NewHorizonPredictor(predictionStore, predictionAccuracy) + predictionHorizon.SetHorizon(prediction.PredictionHorizon) + log.Printf("[INFO] Horizon predictor initialized (%dm horizon, 1000 Monte Carlo runs)", + int(prediction.PredictionHorizon.Minutes())) + } + log.Printf("[INFO] Presence prediction initialized") } @@ -220,6 +298,62 @@ func main() { notifyService.SetRoomConfig(fleetReg) } + // Phase 6: Self-improving localization system + var selfImprovingLocalizer *localization.SelfImprovingLocalizer + var weightStore *localization.WeightStore + + // Get room configuration from fleet registry + roomWidth := 10.0 + roomDepth := 10.0 + originX := 0.0 + originZ := 0.0 + if fleetReg != nil { + if w, d, ox, oz, ok := fleetReg.GetRoomConfig(); ok { + roomWidth = w + roomDepth = d + originX = ox + originZ = oz + } + } + + silConfig := localization.DefaultSelfImprovingConfig() + silConfig.RoomWidth = roomWidth + silConfig.RoomDepth = roomDepth + silConfig.OriginX = originX + silConfig.OriginZ = originZ + silConfig.AdjustmentInterval = 10 * time.Second + + selfImprovingLocalizer = localization.NewSelfImprovingLocalizer(silConfig) + + // Load persisted weights + weightStore, err = localization.NewWeightStore(filepath.Join(cfg.DataDir, "weights.db")) + if err != nil { + log.Printf("[WARN] Failed to open weight store: %v (learning persistence disabled)", err) + } else { + defer weightStore.Close() + savedWeights, loadErr := weightStore.LoadWeights() + if loadErr != nil { + log.Printf("[WARN] Failed to load saved weights: %v", loadErr) + } else if savedWeights != nil { + selfImprovingLocalizer.GetEngine().SetLearnedWeights(savedWeights) + stats := savedWeights.GetAllStats() + log.Printf("[INFO] Loaded %d saved link weights from weight store", len(stats)) + } + } + + // Set node positions from fleet registry + if fleetReg != nil { + nodes, _ := fleetReg.GetAllNodes() + for mac, node := range nodes { + selfImprovingLocalizer.SetNodePosition(mac, node.PosX, node.PosZ) + } + } + + // Start the self-improving localization system + selfImprovingLocalizer.Start() + log.Printf("[INFO] Self-improving localization started (room: %.1fx%.1fm, interval: %v)", + roomWidth, roomDepth, silConfig.AdjustmentInterval) + // Phase 6: Learning feedback store for detection accuracy var feedbackStore *learning.FeedbackStore var feedbackProcessor *learning.Processor @@ -280,11 +414,17 @@ func main() { // Phase 5: Link weather diagnostics weatherDiagnostics := fleet.NewLinkWeatherDiagnostics() + // Phase 6: Role optimiser with GDOP-based coverage optimization + roleOptimiser := fleet.NewRoleOptimiser(fleet.DefaultOptimisationConfig()) + + // Phase 6: Self-healing manager with 5-minute reconnect grace period + selfHealManager := fleet.NewSelfHealManager(fleetReg, roleOptimiser, fleet.DefaultSelfHealConfig()) + // Legacy fleet manager (kept for basic operations) fleetMgr := fleet.NewManager(fleetReg) - // Phase 5: Multi-notifier broadcasts node events to both legacy manager and healer - multiNotify := newMultiNotifier(fleetMgr, fleetHealer) + // Phase 5: Multi-notifier broadcasts node events to legacy manager, healer, and self-heal manager + multiNotify := newMultiNotifier(fleetMgr, fleetHealer, selfHealManager) ingestSrv.SetFleetNotifier(multiNotify) // Adaptive rate controller @@ -306,6 +446,12 @@ func main() { // Phase 6: Wire BLE messages to registry and identity matcher ingestSrv.SetBLEHandler(func(nodeMAC string, devices []ingestion.BLEDevice) { + // Get current security mode + isSecurityMode := false + if automationEngine != nil { + isSecurityMode = automationEngine.GetSystemMode() == automation.ModeAway + } + // Convert ingestion.BLEDevice to ble.BLEObservation and process observations := make([]ble.BLEObservation, len(devices)) for i, dev := range devices { @@ -318,6 +464,16 @@ func main() { } // Update RSSI cache for real-time triangulation rssiCache.AddWithTime(dev.Addr, nodeMAC, dev.RSSIdBm, time.Now()) + + // Feed to self-improving localizer for ground truth + if selfImprovingLocalizer != nil { + selfImprovingLocalizer.AddBLEObservation(dev.Addr, nodeMAC, float64(dev.RSSIdBm)) + } + + // Process BLE device for anomaly detection (security mode) + if anomalyDetector != nil && isSecurityMode { + anomalyDetector.ProcessBLEDevice(dev.Addr, dev.RSSIdBm, isSecurityMode) + } } // Store in persistent registry if bleRegistry != nil { @@ -339,6 +495,54 @@ func main() { } }() + // Phase 6: Wire anomaly detector providers (after dashboardHub and notifyService are ready) + if anomalyDetector != nil { + // Wire providers for anomaly detector + if zonesMgr != nil { + anomalyDetector.SetZoneProvider(&anomalyZoneAdapter{mgr: zonesMgr}) + } + if bleRegistry != nil { + anomalyDetector.SetPersonProvider(&anomalyPersonAdapter{registry: bleRegistry}) + anomalyDetector.SetDeviceProvider(&anomalyDeviceAdapter{registry: bleRegistry}) + } + anomalyDetector.SetPositionProvider(&anomalyPositionAdapter{pm: pm}) + if notifyService != nil { + anomalyDetector.SetAlertHandler(&anomalyAlertAdapter{hub: dashboardHub, notifyService: notifyService}) + } + // Wire feedback store for accuracy tracking + if feedbackStore != nil { + anomalyDetector.SetFeedbackStore(feedbackStore) + } + + // Set callback to broadcast anomalies to dashboard + anomalyDetector.SetOnAnomaly(func(event events.AnomalyEvent) { + msg := map[string]interface{}{ + "type": "anomaly", + "id": event.ID, + "anomaly_type": event.Type, + "score": event.Score, + "description": event.Description, + "zone_id": event.ZoneID, + "zone_name": event.ZoneName, + "timestamp": event.Timestamp.Unix(), + } + data, _ := json.Marshal(msg) + dashboardHub.Broadcast(data) + }) + + // Load registered devices from BLE registry + if bleRegistry != nil { + devices, err := bleRegistry.GetAllRegisteredDevices() + if err == nil { + var macs []string + for mac := range devices { + macs = append(macs, mac) + } + anomalyDetector.SetRegisteredDevices(macs) + } + } + } + // Wire fleet notifier/broadcaster and start self-healing loop fleetMgr.SetNotifier(ingestSrv) fleetMgr.SetBroadcaster(dashboardHub) @@ -349,6 +553,15 @@ func main() { fleetHealer.SetBroadcaster(dashboardHub) go fleetHealer.Run(ctx) + // Phase 6: Wire self-healing manager with grace period for fleet_change events + selfHealManager.SetNotifier(ingestSrv) + selfHealManager.SetBroadcaster(dashboardHub) + if selfImprovingLocalizer != nil { + selfHealManager.SetGDOPCalculator(selfImprovingLocalizer.GetEngine()) + roleOptimiser.SetGDOPCalculator(selfImprovingLocalizer.GetEngine()) + } + go selfHealManager.Run(ctx) + // Phase 5: Wire weather diagnostics with node position accessor weatherDiagnostics.SetNodePositionAccessor(func(mac string) (x, z float64, ok bool) { node, err := fleetReg.GetNode(mac) @@ -554,6 +767,63 @@ func main() { return "" }) } + + // Process anomaly detection + if anomalyDetector != nil && zonesMgr != nil { + // Get current system mode for security mode checks + isSecurityMode := false + if automationEngine != nil { + isSecurityMode = automationEngine.GetSystemMode() == automation.ModeAway + } + + // Process occupancy for each zone + zones := zonesMgr.GetAllZones() + for _, zone := range zones { + occ := zonesMgr.GetZoneOccupancy(zone.ID) + if occ == nil { + continue + } + + // Get BLE devices in this zone + var bleDevices []string + if identityMatcher != nil { + for _, blobID := range occ.BlobIDs { + if match := identityMatcher.GetMatch(blobID); match != nil && match.DeviceMAC != "" { + bleDevices = append(bleDevices, match.DeviceMAC) + } + } + } + + // Process occupancy for unusual hour detection + anomalyDetector.ProcessOccupancy(zone.ID, occ.Count, bleDevices, isSecurityMode) + + // Process motion during away + if isSecurityMode && occ.Count > 0 { + for _, blobID := range occ.BlobIDs { + anomalyDetector.ProcessMotionDuringAway(zone.ID, blobID, true) + } + } + + // Process dwell duration for each person in the zone + for _, blobID := range occ.BlobIDs { + // Get dwell duration from zones manager + if dwellTime, ok := zonesMgr.GetBlobDwellTime(blobID, zone.ID); ok && dwellTime > 5*time.Minute { + // Get person ID for this blob + var personID string + if identityMatcher != nil { + if match := identityMatcher.GetMatch(blobID); match != nil { + personID = match.PersonID + } + } + if personID != "" { + // Check for unusual dwell (fall detection takes priority) + fallDetected := fallDetector.IsFallDetected(blobID) + anomalyDetector.ProcessDwellDuration(zone.ID, personID, dwellTime, isSecurityMode, fallDetected) + } + } + } + } + } } } }() @@ -619,7 +889,7 @@ func main() { // Set identity function for fall detector fallDetector.SetIdentityFunc(func(blobID int) string { - if identityMatcher == nil { + if identityMatcher != nil { if match := identityMatcher.GetMatch(blobID); match != nil { return match.DeviceName } @@ -833,6 +1103,86 @@ func main() { log.Printf("[INFO] Flow analytics background tasks started (prune: 24h, corridors: 7d)") } + // Phase 6: Self-improving localization fusion loop + go func() { + ticker := time.NewTicker(100 * time.Millisecond) // 10 Hz, same as main tracking + defer ticker.Stop() + + weightSaveTicker := time.NewTicker(30 * time.Second) + defer weightSaveTicker.Stop() + + for { + select { + case <-ctx.Done(): + // Final weight save on shutdown + if weightStore != nil && selfImprovingLocalizer != nil { + weights := selfImprovingLocalizer.GetEngine().GetLearnedWeights() + if weights != nil { + if err := weightStore.SaveWeights(weights); err != nil { + log.Printf("[WARN] Failed to save weights on shutdown: %v", err) + } else { + log.Printf("[INFO] Saved learned weights on shutdown") + } + } + } + return + + case <-ticker.C: + if selfImprovingLocalizer == nil { + continue + } + + // Get motion states from signal processor + states := pm.GetAllMotionStates() + if len(states) == 0 { + continue + } + + // Convert to localization.LinkMotion format + links := make([]localization.LinkMotion, 0, len(states)) + for _, state := range states { + // Parse linkID format "nodeMAC-peerMAC" + parts := splitLinkID(state.LinkID) + if len(parts) != 2 { + continue + } + + link := localization.LinkMotion{ + NodeMAC: parts[0], + PeerMAC: parts[1], + DeltaRMS: state.SmoothDeltaRMS, + Motion: state.MotionDetected, + HealthScore: state.BaselineConf, + } + + // Use health score if available + if state.AmbientConfidence > 0 { + link.HealthScore = state.AmbientConfidence + } + + links = append(links, link) + } + + // Run fusion with learned weights + if len(links) > 0 { + selfImprovingLocalizer.Fuse(links) + } + + case <-weightSaveTicker.C: + // Periodic weight persistence + if weightStore != nil && selfImprovingLocalizer != nil { + weights := selfImprovingLocalizer.GetEngine().GetLearnedWeights() + if weights != nil { + if err := weightStore.SaveWeights(weights); err != nil { + log.Printf("[WARN] Failed to save weights: %v", err) + } + } + } + } + } + }() + log.Printf("[INFO] Self-improving localization fusion started (rate: 10Hz, save interval: 30s)") + // Phase 6: Prediction provider wiring and update loop if predictionPredictor != nil && predictionHistory != nil { // Wire zone provider @@ -899,6 +1249,67 @@ func main() { } }() log.Printf("[INFO] Prediction update loop started (interval: 60s)") + + // Start periodic prediction evaluation loop (every 30 seconds) + // This evaluates pending predictions against actual positions + if predictionAccuracy != nil { + go func() { + evalTicker := time.NewTicker(30 * time.Second) + defer evalTicker.Stop() + + // Cleanup ticker (hourly) + cleanupTicker := time.NewTicker(1 * time.Hour) + defer cleanupTicker.Stop() + + // Zone pattern computation ticker (daily) + patternTicker := time.NewTicker(24 * time.Hour) + defer patternTicker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-evalTicker.C: + // Get current actual positions from history updater + actualPositions := make(map[string]string) + if predictionHistory != nil { + zones := predictionHistory.GetAllPersonZones() + for personID, info := range zones { + actualPositions[personID] = info.ZoneID + } + } + + // Evaluate pending predictions + if len(actualPositions) > 0 { + evaluated, correct, err := predictionAccuracy.EvaluatePending(actualPositions) + if err != nil { + log.Printf("[WARN] Prediction evaluation failed: %v", err) + } else if evaluated > 0 { + accuracy := float64(0) + if evaluated > 0 { + accuracy = float64(correct) / float64(evaluated) * 100 + } + log.Printf("[INFO] Prediction evaluation: %d evaluated, %d correct (%.1f%% accuracy)", + evaluated, correct, accuracy) + } + } + + case <-cleanupTicker.C: + // Cleanup old predictions + if err := predictionAccuracy.CleanupOldPredictions(); err != nil { + log.Printf("[WARN] Prediction cleanup failed: %v", err) + } + + case <-patternTicker.C: + // Compute zone occupancy patterns + if err := predictionAccuracy.ComputeZoneOccupancyPatterns(); err != nil { + log.Printf("[WARN] Zone pattern computation failed: %v", err) + } + } + } + }() + log.Printf("[INFO] Prediction evaluation loop started (interval: 30s)") + } } // Fleet REST API @@ -1470,6 +1881,138 @@ func main() { learningHandler.RegisterRoutes(r) } + // Phase 6: Self-improving localization REST API + if selfImprovingLocalizer != nil { + r.Get("/api/localization/progress", func(w http.ResponseWriter, r *http.Request) { + progress := selfImprovingLocalizer.GetLearningProgress() + writeJSON(w, progress) + }) + + r.Get("/api/localization/weights", func(w http.ResponseWriter, r *http.Request) { + weights := selfImprovingLocalizer.GetLearnedWeights() + writeJSON(w, weights) + }) + + r.Get("/api/localization/ground-truth", func(w http.ResponseWriter, r *http.Request) { + allGT := selfImprovingLocalizer.GetAllGroundTruth() + writeJSON(w, allGT) + }) + + r.Get("/api/localization/sigmas", func(w http.ResponseWriter, r *http.Request) { + engine := selfImprovingLocalizer.GetEngine() + if engine == nil || engine.GetLearnedWeights() == nil { + writeJSON(w, map[string]float64{}) + return + } + sigmas := engine.GetLearnedWeights().GetAllSigmas() + writeJSON(w, sigmas) + }) + + r.Get("/api/localization/stats", func(w http.ResponseWriter, r *http.Request) { + engine := selfImprovingLocalizer.GetEngine() + if engine == nil || engine.GetLearnedWeights() == nil { + writeJSON(w, map[string]interface{}{ + "links": 0, + "error": "engine not available", + }) + return + } + stats := engine.GetLearnedWeights().GetAllStats() + result := make(map[string]interface{}) + totalObs := int64(0) + totalCorrect := int64(0) + for linkID, s := range stats { + result[linkID] = map[string]interface{}{ + "observation_count": s.ObservationCount, + "correct_count": s.CorrectCount, + "avg_error_m": s.ErrorSum / math.Max(1, float64(s.ObservationCount)), + "last_error_m": s.LastError, + "weight_adjustments": s.WeightAdjustments, + } + totalObs += s.ObservationCount + totalCorrect += s.CorrectCount + } + result["_summary"] = map[string]interface{}{ + "total_links": len(stats), + "total_observations": totalObs, + "total_correct": totalCorrect, + "accuracy": float64(totalCorrect) / math.Max(1, float64(totalObs)), + } + writeJSON(w, result) + }) + + r.Post("/api/localization/reset", func(w http.ResponseWriter, r *http.Request) { + // Reset all learned weights to defaults + engine := selfImprovingLocalizer.GetEngine() + if engine != nil { + engine.SetLearnedWeights(localization.NewLearnedWeights()) + if weightStore != nil { + weightStore.SaveWeights(localization.NewLearnedWeights()) + } + } + writeJSON(w, map[string]string{"status": "reset"}) + }) + + // Improvement tracking endpoint - shows how localization accuracy improves over time + r.Get("/api/localization/improvement", func(w http.ResponseWriter, r *http.Request) { + stats := selfImprovingLocalizer.GetImprovementStats() + history := selfImprovingLocalizer.GetImprovementHistory() + + result := map[string]interface{}{ + "stats": stats, + "history": history, + } + writeJSON(w, result) + }) + + log.Printf("[INFO] Self-improving localization API registered at /api/localization/*") + } + + // Phase 6: Anomaly detection REST API + if anomalyDetector != nil { + anomalyHandler := analytics.NewAnomalyHandler(anomalyDetector) + anomalyHandler.RegisterRoutes(r) + + // Additional security mode endpoint + r.Get("/api/security/status", func(w http.ResponseWriter, r *http.Request) { + isSecurityMode := false + if automationEngine != nil { + isSecurityMode = automationEngine.GetSystemMode() == automation.ModeAway + } + writeJSON(w, map[string]interface{}{ + "security_mode": isSecurityMode, + "model_ready": anomalyDetector.IsModelReady(), + "learning_progress": anomalyDetector.GetLearningProgress(), + "active_anomalies": len(anomalyDetector.GetActiveAnomalies()), + }) + }) + + r.Post("/api/security/acknowledge-all", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Feedback string `json:"feedback"` + By string `json:"acknowledged_by"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + anomalies := anomalyDetector.GetActiveAnomalies() + acknowledged := 0 + for _, a := range anomalies { + if err := anomalyDetector.AcknowledgeAnomaly(a.ID, req.Feedback, req.By); err == nil { + acknowledged++ + } + } + writeJSON(w, map[string]int{"acknowledged": acknowledged, "total": len(anomalies)}) + }) + } + + // Phase 6: Sleep quality REST API + sleepHandler := sleep.NewHandler(sleepMonitor) + sleepHandler.RegisterRoutes(r) + log.Printf("[INFO] Sleep quality API registered at /api/sleep/*") + // OTA firmware server and manager firmwareDir := filepath.Join(cfg.DataDir, "firmware") otaSrv := ota.NewServer(firmwareDir) @@ -1713,124 +2256,180 @@ type predictionZoneAdapter struct { func (z *predictionZoneAdapter) GetZone(id string) (string, bool) { zone := z.mgr.GetZone(id) - if zone == nil { - return "", false - } - return zone.Name, true + +// Anomaly detector provider adapters + +type anomalyZoneAdapter struct { + mgr *zones.Manager } -type predictionPersonAdapter struct { - registry *ble.Registry +func (a *anomalyZoneAdapter) GetZoneName(zoneID string) string { + zone := a.mgr.GetZone(zoneID) + if zone == nil { + return zoneID + } + return zone.Name } -func (p *predictionPersonAdapter) GetPerson(id string) (string, string, bool) { - person, err := p.registry.GetPerson(id) - if err != nil { - return "", "", false - } - return person.Name, person.Color, true +func (a *anomalyZoneAdapter) GetZoneOccupancy(zoneID string) (int, []int) { + occ := a.mgr.GetZoneOccupancy(zoneID) + if occ == nil { + return 0, nil + } + return occ.Count, occ.BlobIDs } -func (p *predictionPersonAdapter) GetAllPeople() ([]struct { - ID string - Name string - Color string -}, error) { - people, err := p.registry.GetPeople() - if err != nil { - return nil, err - } - result := make([]struct { - ID string - Name string - Color string - }, len(people)) - for i, person := range people { - result[i] = struct { - ID string - Name string - Color string - }{ID: person.ID, Name: person.Name, Color: person.Color} - } - return result, nil +type anomalyPersonAdapter struct { + registry *ble.Registry } -type predictionMQTTAdapter struct { - client *mqtt.Client +func (a *anomalyPersonAdapter) GetPersonDevices(personID string) ([]string, error) { + devices, err := a.registry.GetPersonDevices(personID) + if err != nil { + return nil, err + } + macs := make([]string, len(devices)) + for i, d := range devices { + macs[i] = d.Addr + } + return macs, nil } -func (m *predictionMQTTAdapter) Publish(topic string, payload []byte) error { - return m.client.Publish(topic, payload) +func (a *anomalyPersonAdapter) GetAllRegisteredDevices() (map[string]string, error) { + devices, err := a.registry.GetAllPersonDevices() + if err != nil { + return nil, err + } + result := make(map[string]string) + for _, d := range devices { + if d.PersonID != "" { + result[d.Addr] = d.PersonID + } + } + return result, nil } -func (m *predictionMQTTAdapter) IsConnected() bool { - return m.client.IsConnected() +func (a *anomalyPersonAdapter) GetPersonName(personID string) string { + person, err := a.registry.GetPerson(personID) + if err != nil { + return personID + } + return person.Name } -// Prediction provider adapters - -type predictionZoneAdapter struct { - mgr *zones.Manager +type anomalyDeviceAdapter struct { + registry *ble.Registry } -func (z *predictionZoneAdapter) GetZone(id string) (string, bool) { - zone := z.mgr.GetZone(id) - if zone == nil { - return "", false - } - return zone.Name, true +func (a *anomalyDeviceAdapter) IsDeviceRegistered(mac string) bool { + device, err := a.registry.GetDevice(mac) + if err != nil { + return false + } + return device.PersonID != "" && device.Enabled } -type predictionPersonAdapter struct { - registry *ble.Registry +func (a *anomalyDeviceAdapter) IsDeviceSeenBefore(mac string) bool { + device, err := a.registry.GetDevice(mac) + if err != nil { + return false + } + // Consider "seen before" if first seen more than 24 hours ago + return device.FirstSeenAt.Before(time.Now().Add(-24 * time.Hour)) } -func (p *predictionPersonAdapter) GetPerson(id string) (string, string, bool) { - person, err := p.registry.GetPerson(id) - if err != nil { - return "", "", false - } - return person.Name, person.Color, true +func (a *anomalyDeviceAdapter) GetDeviceName(mac string) string { + device, err := a.registry.GetDevice(mac) + if err != nil { + return mac + } + if device.Label != "" { + return device.Label + } + if device.Name != "" { + return device.Name + } + if device.DeviceName != "" { + return device.DeviceName + } + return mac } -func (p *predictionPersonAdapter) GetAllPeople() ([]struct { - ID string - Name string - Color string -}, error) { - people, err := p.registry.GetPeople() - if err != nil { - return nil, err - } - - result := make([]struct { - ID string - Name string - Color string - }, len(people)) - for i, person := range people { - result[i] = struct { - ID string - Name string - Color string - }{ - ID: person.ID, - Name: person.Name, - Color: person.Color, - } - } - return result, nil +type anomalyPositionAdapter struct { + pm *sigproc.ProcessorManager } -type predictionMQTTAdapter struct { - client *mqtt.Client +func (a *anomalyPositionAdapter) GetBlobPosition(blobID int) (x, y, z float64, ok bool) { + blobs := a.pm.GetTrackedBlobs() + for _, blob := range blobs { + if blob.ID == blobID { + return blob.X, blob.Y, blob.Z, true + } + } + return 0, 0, 0, false } -func (m *predictionMQTTAdapter) Publish(topic string, payload []byte) error { - return m.client.Publish(topic, payload) +type anomalyAlertAdapter struct { + hub *dashboard.Hub + notifyService *notify.Service } -func (m *predictionMQTTAdapter) IsConnected() bool { - return m.client.IsConnected() +func (a *anomalyAlertAdapter) SendAlert(event events.AnomalyEvent, immediate bool) error { + if a.notifyService != nil { + priority := 3 + if immediate { + priority = 5 + } + notif := notify.Notification{ + Title: "Security Alert", + Body: event.Description, + Priority: priority, + Tags: []string{"warning", "security", string(event.Type)}, + Data: map[string]interface{}{ + "anomaly_id": event.ID, + "anomaly_type": event.Type, + "score": event.Score, + "zone_id": event.ZoneID, + "zone_name": event.ZoneName, + }, + Timestamp: time.Now(), + } + a.notifyService.Send(notif) + } + return nil } +func (a *anomalyAlertAdapter) SendWebhook(event events.AnomalyEvent, immediate bool) error { + // Webhooks are handled by the notification service channels + return nil +} + +func (a *anomalyAlertAdapter) SendEscalation(event events.AnomalyEvent) error { + if a.notifyService != nil { + notif := notify.Notification{ + Title: "SECURITY ESCALATION", + Body: fmt.Sprintf("UNACKNOWLEDGED: %s", event.Description), + Priority: 5, + Tags: []string{"urgent", "security", "escalation"}, + Data: map[string]interface{}{ + "anomaly_id": event.ID, + "anomaly_type": event.Type, + "escalation": true, + }, + Timestamp: time.Now(), + } + a.notifyService.Send(notif) + } + return nil +} + +// splitLinkID parses a link ID in format "nodeMAC-peerMAC" into its components +func splitLinkID(linkID string) []string { + // Link ID format is "aa:bb:cc:dd:ee:ff-11:22:33:44:55:66" + for i := len(linkID) - 1; i >= 0; i-- { + if linkID[i] == '-' { + return []string{linkID[:i], linkID[i+1:]} + } + } + return nil +} diff --git a/mothership/go.mod b/mothership/go.mod index a56d8bf..0a1057b 100644 --- a/mothership/go.mod +++ b/mothership/go.mod @@ -3,6 +3,7 @@ module github.com/spaxel/mothership go 1.25.0 require ( + github.com/eclipse/paho.mqtt.golang v1.5.0 github.com/go-chi/chi v1.5.5 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 diff --git a/mothership/internal/learning/accuracy.go b/mothership/internal/learning/accuracy.go index 4b48f9c..23c49c3 100644 --- a/mothership/internal/learning/accuracy.go +++ b/mothership/internal/learning/accuracy.go @@ -176,20 +176,7 @@ func (a *AccuracyComputer) getCounts(scopeType, scopeID, week string) (tp, fp, f // getFeedbackInTimeRange retrieves all feedback in a time range func (a *AccuracyComputer) getFeedbackInTimeRange(start, end time.Time) ([]FeedbackRecord, error) { - // This is a simplified implementation - in production you'd have a more efficient query - stats, err := a.store.GetFeedbackStats() - if err != nil { - return nil, err - } - - // For now, use the stats to get counts - // A full implementation would query feedback by timestamp - _ = stats - _ = start - _ = end - - // Return empty for now - actual implementation would query the database - return nil, nil + return a.store.GetFeedbackInTimeRange(start, end) } // matchesScope checks if a feedback record matches the given scope @@ -249,9 +236,12 @@ func (a *AccuracyComputer) computePerZone(week string) error { // getUniqueScopeIDs extracts unique scope IDs from feedback func (a *AccuracyComputer) getUniqueScopeIDs(scopeType string) []string { - // This would query distinct scope IDs from feedback - // Simplified implementation for now - return nil + ids, err := a.store.GetUniqueScopeIDs(scopeType) + if err != nil { + log.Printf("[WARN] Failed to get unique scope IDs for %s: %v", scopeType, err) + return nil + } + return ids } // parseWeekString parses a week string (e.g., "2026-W13") into a time diff --git a/mothership/internal/learning/feedback_store.go b/mothership/internal/learning/feedback_store.go index 2261b7b..aa0c220 100644 --- a/mothership/internal/learning/feedback_store.go +++ b/mothership/internal/learning/feedback_store.go @@ -585,6 +585,93 @@ func (s *FeedbackStore) GetFeedbackByEvent(eventID string) ([]FeedbackRecord, er return records, nil } +// GetFeedbackInTimeRange returns all feedback within a time range +func (s *FeedbackStore) GetFeedbackInTimeRange(start, end time.Time) ([]FeedbackRecord, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + rows, err := s.db.Query(` + SELECT id, event_id, event_type, feedback_type, details_json, timestamp, applied, processed_at + FROM detection_feedback + WHERE timestamp >= ? AND timestamp < ? + ORDER BY timestamp ASC + `, start.Unix(), end.Unix()) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []FeedbackRecord + for rows.Next() { + var r FeedbackRecord + var timestamp int64 + var processedAt sql.NullInt64 + var detailsJSON string + + if err := rows.Scan(&r.ID, &r.EventID, &r.EventType, &r.FeedbackType, + &detailsJSON, ×tamp, &r.Applied, &processedAt); err != nil { + continue + } + + r.Timestamp = time.Unix(timestamp, 0) + if processedAt.Valid { + t := time.Unix(processedAt.Int64, 0) + r.ProcessedAt = &t + } + + if err := json.Unmarshal([]byte(detailsJSON), &r.Details); err != nil { + r.Details = make(map[string]interface{}) + } + + records = append(records, r) + } + + return records, nil +} + +// GetUniqueScopeIDs returns unique scope IDs for a given scope type from feedback +func (s *FeedbackStore) GetUniqueScopeIDs(scopeType string) ([]string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var fieldName string + switch scopeType { + case ScopeTypeLink: + fieldName = "$.link_id" + case ScopeTypeZone: + fieldName = "$.zone_id" + case ScopeTypePerson: + fieldName = "$.person_id" + default: + return nil, nil + } + + // Use JSON extraction to get unique scope IDs + rows, err := s.db.Query(` + SELECT DISTINCT json_extract(details_json, ?) + FROM detection_feedback + WHERE json_extract(details_json, ?) IS NOT NULL + AND json_extract(details_json, ?) != '' + `, fieldName, fieldName, fieldName) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + continue + } + if id != "" { + ids = append(ids, id) + } + } + + return ids, nil +} + // GetFeedbackCount returns the total number of feedback entries func (s *FeedbackStore) GetFeedbackCount() (int, error) { s.mu.RLock()