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:
jedarden 2026-03-29 18:03:45 -04:00
parent d4f16db29c
commit fb218f5410
6 changed files with 1519 additions and 115 deletions

View file

@ -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;\
}\

View file

@ -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\')">&#x1F44D;</button>' +
' <button class="feedback-btn-icon feedback-thumbs-down" title="Incorrect detection" ' +
' onclick="Viz3D.showBlobFeedbackForm(' + blobId + ')">&#x1F44E;</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,
};
})();

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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, &timestamp, &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()