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()