feat(learning): complete feedback loop with time-range queries and zone breakdown
- Add GetFeedbackInTimeRange() to query feedback by timestamp for accuracy computation - Add GetUniqueScopeIDs() to extract unique link/zone/person IDs from feedback - Update accuracy.go to use the new store methods for proper scope filtering - Add zone breakdown visualization in accuracy panel with clickable zone items - Add blob hover tooltips with thumbs up/down feedback buttons in 3D view - Wire up feedback processor and accuracy computer in main startup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d4f16db29c
commit
fb218f5410
6 changed files with 1519 additions and 115 deletions
|
|
@ -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 = '<div class="no-data-text">No zone data yet</div>';
|
||||
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 += '<div class="zone-item" data-zone-id="' + zone.scope_id + '">' +
|
||||
'<span class="zone-name">' + self.formatZoneName(zone.scope_id) + '</span>' +
|
||||
'<span class="zone-score" style="color:' + color + '">' + f1 + '</span>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
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;\
|
||||
}\
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
'<div class="feedback-tooltip-content">' +
|
||||
' <div class="feedback-tooltip-label">Track #' + blobId + '</div>' +
|
||||
' <div class="feedback-tooltip-actions">' +
|
||||
' <button class="feedback-btn-icon feedback-thumbs-up" title="Correct detection" ' +
|
||||
' onclick="Viz3D.submitBlobFeedback(' + blobId + ', \'TRUE_POSITIVE\')">👍</button>' +
|
||||
' <button class="feedback-btn-icon feedback-thumbs-down" title="Incorrect detection" ' +
|
||||
' onclick="Viz3D.showBlobFeedbackForm(' + blobId + ')">👎</button>' +
|
||||
' </div>' +
|
||||
'</div>';
|
||||
|
||||
_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,
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue