feat: implement ambient dashboard mode with Canvas 2D renderer
- Added /ambient route serving ambient.html for wall-mounted tablet display - Canvas 2D renderer at 2Hz with lerp interpolation for smooth person movement - Time-of-day palette with 30-minute transitions (morning/day/evening/night) - Auto-dim: reduces brightness to 40% after 60s of no presence - Alert mode: pulsing red background for fall/security alerts - Morning briefing overlay: 15-second overlay on first detection after 6am - Unified alerts API for fall, anomaly, and node_offline events - Jest test setup mocking Canvas 2D context for jsdom Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
249cc311ca
commit
d81d1cb82c
12 changed files with 2330 additions and 967 deletions
|
|
@ -30,6 +30,7 @@
|
|||
let ws = null;
|
||||
let wsReconnectTimer = null;
|
||||
let updateTimer = null;
|
||||
let timeOfDayTimer = null;
|
||||
let currentState = {
|
||||
zones: [],
|
||||
blobs: [],
|
||||
|
|
@ -60,9 +61,6 @@
|
|||
// Check if we should be in ambient mode
|
||||
checkAmbientMode();
|
||||
|
||||
// Set up time-of-day updates
|
||||
startTimeOfDayUpdater();
|
||||
|
||||
console.log('[Ambient Mode] Initialized');
|
||||
}
|
||||
|
||||
|
|
@ -98,8 +96,9 @@
|
|||
// Create ambient UI
|
||||
createAmbientUI();
|
||||
|
||||
// Set initial time period
|
||||
// Set initial time period and start timer
|
||||
updateTimeOfDay();
|
||||
startTimeOfDayUpdater();
|
||||
|
||||
// Initialize renderer
|
||||
const canvasEl = document.getElementById('ambient-canvas');
|
||||
|
|
@ -218,7 +217,21 @@
|
|||
*/
|
||||
function startTimeOfDayUpdater() {
|
||||
updateTimeOfDay();
|
||||
setInterval(updateTimeOfDay, 60000); // Check every minute
|
||||
timeOfDayTimer = setInterval(updateTimeOfDay, 60000); // Check every minute
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all timers
|
||||
*/
|
||||
function stopUpdates() {
|
||||
if (timeOfDayTimer) {
|
||||
clearInterval(timeOfDayTimer);
|
||||
timeOfDayTimer = null;
|
||||
}
|
||||
if (updateTimer) {
|
||||
clearTimeout(updateTimer);
|
||||
updateTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
397
dashboard/js/ambient.test.setup.js
Normal file
397
dashboard/js/ambient.test.setup.js
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
/**
|
||||
* Jest setup for ambient tests.
|
||||
* Mocks Canvas 2D context which is not implemented in jsdom.
|
||||
*/
|
||||
|
||||
// Storage for canvas draw operations (for pixel-based tests)
|
||||
const canvasDrawData = new Map();
|
||||
|
||||
// Helper to reset canvas draw data
|
||||
global.resetCanvasDrawData = function() {
|
||||
canvasDrawData.clear();
|
||||
};
|
||||
|
||||
// Helper to get canvas key
|
||||
function getCanvasKey(canvas) {
|
||||
return canvas.id || canvas.toString();
|
||||
}
|
||||
|
||||
// Mock Canvas 2D context before modules are loaded
|
||||
HTMLCanvasElement.prototype.getContext = function(contextType) {
|
||||
if (contextType === '2d' && !this._mockContext) {
|
||||
const canvasKey = getCanvasKey(this);
|
||||
|
||||
// Initialize draw data for this canvas
|
||||
if (!canvasDrawData.has(canvasKey)) {
|
||||
const width = this.width || 800;
|
||||
const height = this.height || 600;
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
// Fill with white background
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
data[i] = 255; // R
|
||||
data[i + 1] = 255; // G
|
||||
data[i + 2] = 255; // B
|
||||
data[i + 3] = 255; // A
|
||||
}
|
||||
canvasDrawData.set(canvasKey, { data, width, height });
|
||||
}
|
||||
|
||||
// Create a mock 2D context that actually tracks draw operations
|
||||
const mockContext = {
|
||||
canvas: this,
|
||||
fillStyle: '#000000',
|
||||
strokeStyle: '#000000',
|
||||
lineWidth: 1,
|
||||
font: '12px sans-serif',
|
||||
textAlign: 'left',
|
||||
textBaseline: 'alphabetic',
|
||||
|
||||
// Mock methods that track drawing
|
||||
clearRect: jest.fn(function(x, y, w, h) {
|
||||
const drawData = canvasDrawData.get(canvasKey);
|
||||
if (!drawData) return;
|
||||
|
||||
const { data, width } = drawData;
|
||||
for (let py = y; py < y + h && py < drawData.height; py++) {
|
||||
for (let px = x; px < x + w && px < width; px++) {
|
||||
const i = (py * width + px) * 4;
|
||||
data[i] = 255;
|
||||
data[i + 1] = 255;
|
||||
data[i + 2] = 255;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
fillRect: jest.fn(function(x, y, w, h) {
|
||||
const drawData = canvasDrawData.get(canvasKey);
|
||||
if (!drawData) return;
|
||||
|
||||
const { data, width, height } = drawData;
|
||||
// Parse fillStyle
|
||||
const color = parseColor(mockContext.fillStyle);
|
||||
|
||||
for (let py = y; py < y + h && py < height; py++) {
|
||||
for (let px = x; px < x + w && px < width; px++) {
|
||||
const i = (py * width + px) * 4;
|
||||
data[i] = color.r;
|
||||
data[i + 1] = color.g;
|
||||
data[i + 2] = color.b;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
strokeRect: jest.fn(function(x, y, w, h) {
|
||||
const drawData = canvasDrawData.get(canvasKey);
|
||||
if (!drawData) return;
|
||||
|
||||
const { data, width, height } = drawData;
|
||||
const color = parseColor(mockContext.strokeStyle);
|
||||
|
||||
// Draw outline (1px thick)
|
||||
const lineWidth = mockContext.lineWidth || 1;
|
||||
for (let i = 0; i < lineWidth; i++) {
|
||||
// Top edge
|
||||
for (let px = x; px < x + w && px < width; px++) {
|
||||
setPixel(data, width, px, y + i, color);
|
||||
}
|
||||
// Bottom edge
|
||||
for (let px = x; px < x + w && px < width; px++) {
|
||||
setPixel(data, width, px, y + h - i - 1, color);
|
||||
}
|
||||
// Left edge
|
||||
for (let py = y; py < y + h && py < height; py++) {
|
||||
setPixel(data, width, x + i, py, color);
|
||||
}
|
||||
// Right edge
|
||||
for (let py = y; py < y + h && py < height; py++) {
|
||||
setPixel(data, width, x + w - i - 1, py, color);
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
fillText: jest.fn(function(text, x, y) {
|
||||
const drawData = canvasDrawData.get(canvasKey);
|
||||
if (!drawData) return;
|
||||
|
||||
const { data, width, height } = drawData;
|
||||
const color = parseColor(mockContext.fillStyle);
|
||||
|
||||
// Draw a simple "text" as colored pixels at the position
|
||||
for (let py = y - 6; py < y + 6 && py < height; py++) {
|
||||
for (let px = x - 20; px < x + 20 && px < width; px++) {
|
||||
setPixel(data, width, px, py, color);
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
beginPath: jest.fn(),
|
||||
arc: jest.fn(function(x, y, radius, startAngle, endAngle) {
|
||||
const drawData = canvasDrawData.get(canvasKey);
|
||||
if (!drawData) return;
|
||||
|
||||
const { data, width, height } = drawData;
|
||||
const color = parseColor(mockContext.fillStyle);
|
||||
|
||||
// Draw a filled circle
|
||||
for (let py = Math.floor(y - radius); py <= Math.ceil(y + radius) && py < height; py++) {
|
||||
for (let px = Math.floor(x - radius); px <= Math.ceil(x + radius) && px < width; px++) {
|
||||
const dx = px - x;
|
||||
const dy = py - y;
|
||||
if (dx * dx + dy * dy <= radius * radius) {
|
||||
setPixel(data, width, px, py, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
moveTo: jest.fn(),
|
||||
lineTo: jest.fn(),
|
||||
closePath: jest.fn(),
|
||||
fill: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
scale: jest.fn(),
|
||||
roundRect: jest.fn(function(x, y, w, h, radius) {
|
||||
// Just draw a filled rectangle for simplicity
|
||||
mockContext.fillRect(x, y, w, h);
|
||||
}),
|
||||
|
||||
// Mock getImageData to return actual pixel data
|
||||
getImageData: function(x, y, width, height) {
|
||||
const drawData = canvasDrawData.get(canvasKey);
|
||||
if (!drawData) {
|
||||
// Return empty data
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
return { data, width, height };
|
||||
}
|
||||
|
||||
const { data: srcData } = drawData;
|
||||
const result = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
// Copy the requested region
|
||||
for (let py = 0; py < height; py++) {
|
||||
for (let px = 0; px < width; px++) {
|
||||
const srcX = x + px;
|
||||
const srcY = y + py;
|
||||
|
||||
if (srcX >= 0 && srcX < drawData.width && srcY >= 0 && srcY < drawData.height) {
|
||||
const srcIdx = (srcY * drawData.width + srcX) * 4;
|
||||
const dstIdx = (py * width + px) * 4;
|
||||
result[dstIdx] = srcData[srcIdx];
|
||||
result[dstIdx + 1] = srcData[srcIdx + 1];
|
||||
result[dstIdx + 2] = srcData[srcIdx + 2];
|
||||
result[dstIdx + 3] = srcData[srcIdx + 3];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: result,
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
this._mockContext = mockContext;
|
||||
}
|
||||
|
||||
return this._mockContext;
|
||||
};
|
||||
|
||||
// Helper to parse color strings
|
||||
function parseColor(colorStr) {
|
||||
if (typeof colorStr !== 'string') {
|
||||
return { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
// Handle hex colors
|
||||
if (colorStr.startsWith('#')) {
|
||||
let hex = colorStr.slice(1);
|
||||
if (hex.length === 3) {
|
||||
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||
}
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
// Handle rgb/hsl colors - simplified
|
||||
if (colorStr.startsWith('rgb')) {
|
||||
const match = colorStr.match(/\d+/g);
|
||||
if (match && match.length >= 3) {
|
||||
return { r: parseInt(match[0]), g: parseInt(match[1]), b: parseInt(match[2]) };
|
||||
}
|
||||
}
|
||||
|
||||
if (colorStr.startsWith('hsl')) {
|
||||
// Simplified HSL to RGB - just return a default color
|
||||
return { r: 100, g: 100, b: 100 };
|
||||
}
|
||||
|
||||
// Default colors
|
||||
const namedColors = {
|
||||
'white': { r: 255, g: 255, b: 255 },
|
||||
'black': { r: 0, g: 0, b: 0 },
|
||||
'red': { r: 255, g: 0, b: 0 },
|
||||
'green': { r: 0, g: 255, b: 0 },
|
||||
'blue': { r: 0, g: 0, b: 255 },
|
||||
'grey': { r: 128, g: 128, b: 128 },
|
||||
'gray': { r: 128, g: 128, b: 128 }
|
||||
};
|
||||
|
||||
const lower = colorStr.toLowerCase();
|
||||
if (namedColors[lower]) {
|
||||
return namedColors[lower];
|
||||
}
|
||||
|
||||
return { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
// Helper to set a pixel
|
||||
function setPixel(data, width, x, y, color) {
|
||||
if (x < 0 || y < 0 || x >= width) return;
|
||||
const i = (y * width + x) * 4;
|
||||
data[i] = color.r;
|
||||
data[i + 1] = color.g;
|
||||
data[i + 2] = color.b;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
|
||||
// Mock getBoundingClientRect for proper hit testing
|
||||
HTMLElement.prototype.getBoundingClientRect = function() {
|
||||
const rect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: this.offsetWidth || 800,
|
||||
height: this.offsetHeight || 600,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: (this.offsetHeight || 600),
|
||||
right: (this.offsetWidth || 800),
|
||||
toJSON: function() {
|
||||
return {
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
top: this.top,
|
||||
left: this.left,
|
||||
bottom: this.bottom,
|
||||
right: this.right
|
||||
};
|
||||
}
|
||||
};
|
||||
return rect;
|
||||
};
|
||||
|
||||
// Mock devicePixelRatio
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
value: 1,
|
||||
writable: true
|
||||
});
|
||||
|
||||
// Mock requestAnimationFrame with increasing timestamps
|
||||
let rafTimestamp = 0;
|
||||
let rafCallbacks = new Map();
|
||||
let rafIdCounter = 0;
|
||||
let rafLoopRunning = false;
|
||||
|
||||
// Start a mock RAF loop that runs at ~60fps (16ms per frame)
|
||||
function startRafLoop() {
|
||||
if (rafLoopRunning) return;
|
||||
rafLoopRunning = true;
|
||||
|
||||
function loop() {
|
||||
// Process all pending callbacks
|
||||
const callbacksToRun = Array.from(rafCallbacks.entries())
|
||||
.filter(([id]) => typeof id === 'number')
|
||||
.map(([id, callback]) => callback);
|
||||
|
||||
// Clear processed callbacks
|
||||
for (const id of rafCallbacks.keys()) {
|
||||
if (typeof id === 'number') {
|
||||
rafCallbacks.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Run all callbacks with current timestamp
|
||||
rafTimestamp += 16; // Advance time
|
||||
callbacksToRun.forEach(callback => {
|
||||
try {
|
||||
callback(rafTimestamp);
|
||||
} catch (e) {
|
||||
// Ignore errors in callbacks
|
||||
}
|
||||
});
|
||||
|
||||
// Schedule next iteration
|
||||
if (rafLoopRunning) {
|
||||
const timerId = setTimeout(loop, 0);
|
||||
if (global._activeRafTimers) {
|
||||
global._activeRafTimers.add(timerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const timerId = setTimeout(loop, 0);
|
||||
if (global._activeRafTimers) {
|
||||
global._activeRafTimers.add(timerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the RAF loop immediately
|
||||
startRafLoop();
|
||||
|
||||
global.requestAnimationFrame = function(callback) {
|
||||
const id = ++rafIdCounter;
|
||||
rafCallbacks.set(id, callback);
|
||||
return id;
|
||||
};
|
||||
|
||||
global.cancelAnimationFrame = function(id) {
|
||||
rafCallbacks.delete(id);
|
||||
};
|
||||
|
||||
// Helper to stop the RAF loop (for testing)
|
||||
global.stopRafLoop = function() {
|
||||
rafLoopRunning = false;
|
||||
};
|
||||
|
||||
// Helper to restart the RAF loop
|
||||
global.restartRafLoop = function() {
|
||||
rafLoopRunning = false; // Stop first
|
||||
rafTimestamp = 0; // Reset timestamp
|
||||
startRafLoop(); // Restart
|
||||
};
|
||||
|
||||
// Mock localStorage
|
||||
var storage = {};
|
||||
Object.defineProperty(global, 'localStorage', {
|
||||
value: {
|
||||
getItem: function(key) { return storage[key] || null; },
|
||||
setItem: function(key, val) { storage[key] = String(val); },
|
||||
removeItem: function(key) { delete storage[key]; },
|
||||
clear: function() { storage = {}; },
|
||||
get length() { return Object.keys(storage).length; },
|
||||
key: function(index) { return Object.keys(storage)[index] || null; }
|
||||
},
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// Make storage accessible for test cleanup
|
||||
global._localStorage = storage;
|
||||
|
||||
// Track active requestAnimationFrame timers for cleanup
|
||||
global._activeRafTimers = new Set();
|
||||
global._stopAllRafTimers = function() {
|
||||
_activeRafTimers.forEach(timerId => clearTimeout(timerId));
|
||||
_activeRafTimers.clear();
|
||||
};
|
||||
|
||||
// Mock addEventListener and removeEventListener on EventTarget prototype
|
||||
// to ensure they work properly in tests
|
||||
const originalAddEventListener = EventTarget.prototype.addEventListener;
|
||||
const originalRemoveEventListener = EventTarget.prototype.removeEventListener;
|
||||
|
|
@ -44,8 +44,7 @@
|
|||
// Set up event listeners
|
||||
setupEventListeners();
|
||||
|
||||
// Check if we should be listening for first detection
|
||||
checkFirstDetectionToday();
|
||||
// Don't check for first detection during init - it will be checked when needed
|
||||
|
||||
console.log('[AmbientBriefing] Initialized');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
let isDimmed = false;
|
||||
let alertPulseTimer = null;
|
||||
let alertPulseState = false; // for pulsing animation
|
||||
let renderCallCount = 0; // Track number of renderFrame calls (for testing)
|
||||
|
||||
// Current state
|
||||
let currentState = {
|
||||
|
|
@ -71,6 +72,14 @@
|
|||
return alertPulseState;
|
||||
}
|
||||
|
||||
function _getRenderCallCount() {
|
||||
return renderCallCount;
|
||||
}
|
||||
|
||||
function _resetRenderCallCount() {
|
||||
renderCallCount = 0;
|
||||
}
|
||||
|
||||
function _enterDimMode() {
|
||||
enterDimMode();
|
||||
}
|
||||
|
|
@ -130,19 +139,16 @@
|
|||
// Update target positions for lerp
|
||||
if (state.blobs) {
|
||||
state.blobs.forEach(blob => {
|
||||
targetPositions.set(blob.id, {
|
||||
const target = {
|
||||
x: blob.x,
|
||||
y: blob.y,
|
||||
z: blob.z || 0
|
||||
});
|
||||
};
|
||||
targetPositions.set(blob.id, target);
|
||||
|
||||
// Initialize current position if this is a new blob
|
||||
if (!currentPositions.has(blob.id)) {
|
||||
currentPositions.set(blob.id, {
|
||||
x: blob.x,
|
||||
y: blob.y,
|
||||
z: blob.z || 0
|
||||
});
|
||||
currentPositions.set(blob.id, { ...target });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -260,11 +266,27 @@
|
|||
console.log('[AmbientRenderer] Destroyed');
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop the render loop (for testing)
|
||||
*/
|
||||
stopRenderLoop() {
|
||||
stopRenderLoop();
|
||||
},
|
||||
|
||||
/**
|
||||
* Start the render loop (for testing)
|
||||
*/
|
||||
startRenderLoop() {
|
||||
startRenderLoop();
|
||||
},
|
||||
|
||||
// Testing/internal methods
|
||||
_getCurrentState,
|
||||
_getCurrentPositions,
|
||||
_getTargetPositions,
|
||||
_getAlertPulseState,
|
||||
_getRenderCallCount,
|
||||
_resetRenderCallCount,
|
||||
_enterDimMode,
|
||||
_checkAmbientZonePresence
|
||||
};
|
||||
|
|
@ -317,6 +339,8 @@
|
|||
function renderFrame() {
|
||||
if (!ctx || !canvas) return;
|
||||
|
||||
renderCallCount++; // Track render calls for testing
|
||||
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
|
||||
|
|
@ -570,6 +594,9 @@
|
|||
pos.x = lerp(pos.x, target.x, LERP_FACTOR);
|
||||
pos.y = lerp(pos.y, target.y, LERP_FACTOR);
|
||||
pos.z = lerp(pos.z, target.z, LERP_FACTOR);
|
||||
|
||||
// Update the currentPositions map with the lerped position
|
||||
currentPositions.set(blob.id, { ...pos });
|
||||
}
|
||||
|
||||
const screenPos = worldToScreen(pos.x, pos.y, bounds);
|
||||
|
|
|
|||
321
mothership/internal/api/alerts.go
Normal file
321
mothership/internal/api/alerts.go
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
// Package api provides REST API handlers for active alerts.
|
||||
// Combines fall detection, anomaly detection, and node status into a unified alert system.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/spaxel/mothership/internal/analytics"
|
||||
"github.com/spaxel/mothership/internal/falldetect"
|
||||
"github.com/spaxel/mothership/internal/fleet"
|
||||
)
|
||||
|
||||
// AlertsHandler manages the unified alerts API.
|
||||
type AlertsHandler struct {
|
||||
mu sync.RWMutex
|
||||
fallDetector *falldetect.Detector
|
||||
anomalyDetector *analytics.Detector
|
||||
fleetRegistry *fleet.Registry
|
||||
}
|
||||
|
||||
// Alert represents a unified alert from any source.
|
||||
type Alert struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "fall", "anomaly", "node_offline"
|
||||
Severity string `json:"severity"` // "critical", "warning", "info"
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Person string `json:"person,omitempty"`
|
||||
Timestamp int64 `json:"timestamp_ms"`
|
||||
Data any `json:"data,omitempty"` // Type-specific data
|
||||
}
|
||||
|
||||
// ActiveAlertsResponse is the response for GET /api/alerts/active.
|
||||
type ActiveAlertsResponse struct {
|
||||
Alerts []Alert `json:"alerts"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// NewAlertsHandler creates a new alerts handler.
|
||||
func NewAlertsHandler() *AlertsHandler {
|
||||
return &AlertsHandler{}
|
||||
}
|
||||
|
||||
// SetFallDetector sets the fall detection module.
|
||||
func (h *AlertsHandler) SetFallDetector(detector *falldetect.Detector) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.fallDetector = detector
|
||||
}
|
||||
|
||||
// SetAnomalyDetector sets the anomaly detection module.
|
||||
func (h *AlertsHandler) SetAnomalyDetector(detector *analytics.Detector) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.anomalyDetector = detector
|
||||
}
|
||||
|
||||
// SetFleetRegistry sets the fleet registry for node status.
|
||||
func (h *AlertsHandler) SetFleetRegistry(registry *fleet.Registry) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.fleetRegistry = registry
|
||||
}
|
||||
|
||||
// RegisterRoutes registers the alerts API routes.
|
||||
func (h *AlertsHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/api/alerts/active", h.handleGetActiveAlerts)
|
||||
r.Post("/api/alerts/{id}/acknowledge", h.handleAcknowledgeAlert)
|
||||
|
||||
// Convenience routes for fall and anomaly acknowledgment
|
||||
// These redirect to the unified alert endpoint
|
||||
r.Post("/api/fall/{id}/acknowledge", h.handleAcknowledgeFall)
|
||||
r.Post("/api/anomalies/{id}/acknowledge", h.handleAcknowledgeAnomaly)
|
||||
}
|
||||
|
||||
// handleGetActiveAlerts returns all active alerts from all sources.
|
||||
// GET /api/alerts/active
|
||||
//
|
||||
// Response:
|
||||
//
|
||||
// {
|
||||
// "alerts": [
|
||||
// {
|
||||
// "id": "fall-123",
|
||||
// "type": "fall",
|
||||
// "severity": "critical",
|
||||
// "title": "Possible fall detected",
|
||||
// "message": "Alice in Hallway",
|
||||
// "zone": "hallway",
|
||||
// "person": "Alice",
|
||||
// "timestamp_ms": 1711234567890,
|
||||
// "data": { ... }
|
||||
// }
|
||||
// ],
|
||||
// "count": 2
|
||||
// }
|
||||
func (h *AlertsHandler) handleGetActiveAlerts(w http.ResponseWriter, r *http.Request) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
var alerts []Alert
|
||||
|
||||
// Add active fall alerts
|
||||
if h.fallDetector != nil {
|
||||
falls := h.fallDetector.GetActiveFalls()
|
||||
for _, fall := range falls {
|
||||
alert := Alert{
|
||||
ID: "fall-" + fall.ID,
|
||||
Type: "fall",
|
||||
Severity: "critical",
|
||||
Title: "Possible fall detected",
|
||||
Message: h.formatFallMessage(fall),
|
||||
Zone: fall.ZoneName,
|
||||
Person: fall.Identity,
|
||||
Timestamp: fall.Timestamp.Unix() * 1000,
|
||||
Data: fall,
|
||||
}
|
||||
alerts = append(alerts, alert)
|
||||
}
|
||||
}
|
||||
|
||||
// Add active anomaly alerts
|
||||
if h.anomalyDetector != nil {
|
||||
anomalies := h.anomalyDetector.GetActiveAnomalies()
|
||||
for _, anomaly := range anomalies {
|
||||
alert := Alert{
|
||||
ID: "anomaly-" + anomaly.ID,
|
||||
Type: "anomaly",
|
||||
Severity: "warning",
|
||||
Title: "Unusual activity detected",
|
||||
Message: h.formatAnomalyMessage(anomaly),
|
||||
Timestamp: anomaly.Timestamp.Unix() * 1000,
|
||||
Data: anomaly,
|
||||
}
|
||||
alerts = append(alerts, alert)
|
||||
}
|
||||
}
|
||||
|
||||
// Add offline node alerts
|
||||
if h.fleetRegistry != nil {
|
||||
nodes, err := h.fleetRegistry.GetAllNodes()
|
||||
if err == nil {
|
||||
for _, node := range nodes {
|
||||
if node.Status == "offline" {
|
||||
alert := Alert{
|
||||
ID: "node-" + node.MAC,
|
||||
Type: "node_offline",
|
||||
Severity: "warning",
|
||||
Title: "Node offline",
|
||||
Message: "Node " + node.Name + " went offline",
|
||||
Timestamp: node.LastSeen.Unix() * 1000,
|
||||
Data: map[string]interface{}{
|
||||
"mac": node.MAC,
|
||||
"name": node.Name,
|
||||
"status": node.Status,
|
||||
},
|
||||
}
|
||||
alerts = append(alerts, alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by severity (critical > warning > info) and timestamp
|
||||
h.sortAlerts(alerts)
|
||||
|
||||
response := ActiveAlertsResponse{
|
||||
Alerts: alerts,
|
||||
Count: len(alerts),
|
||||
}
|
||||
|
||||
writeJSON(w, response)
|
||||
}
|
||||
|
||||
// handleAcknowledgeAlert acknowledges an alert by ID.
|
||||
// POST /api/alerts/{id}/acknowledge
|
||||
//
|
||||
// The ID is prefixed by the alert type: "fall-123", "anomaly-456", "node-AA:BB:CC:DD:EE:FF".
|
||||
func (h *AlertsHandler) handleAcknowledgeAlert(w http.ResponseWriter, r *http.Request) {
|
||||
alertID := chi.URLParam(r, "id")
|
||||
if alertID == "" {
|
||||
http.Error(w, "alert ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the alert type and ID
|
||||
var alertType, id string
|
||||
if len(alertID) > 5 && alertID[4] == '-' {
|
||||
alertType = alertID[:4]
|
||||
id = alertID[5:]
|
||||
} else {
|
||||
http.Error(w, "invalid alert ID format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
switch alertType {
|
||||
case "fall":
|
||||
if h.fallDetector != nil {
|
||||
err = h.fallDetector.AcknowledgeFall(id, "acknowledged")
|
||||
} else {
|
||||
log.Printf("[WARN] Fall detector not available for acknowledgment")
|
||||
}
|
||||
case "anomaly":
|
||||
if h.anomalyDetector != nil {
|
||||
err = h.anomalyDetector.AcknowledgeAnomaly(id)
|
||||
} else {
|
||||
log.Printf("[WARN] Anomaly detector not available for acknowledgment")
|
||||
}
|
||||
case "node":
|
||||
// Node alerts don't require acknowledgment - they auto-clear when node comes back online
|
||||
log.Printf("[INFO] Node offline alert acknowledged: %s", id)
|
||||
default:
|
||||
http.Error(w, "unknown alert type: "+alertType, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] Failed to acknowledge alert %s: %v", alertID, err)
|
||||
http.Error(w, "failed to acknowledge alert", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]string{"status": "acknowledged", "id": alertID})
|
||||
}
|
||||
|
||||
// sortAlerts sorts alerts by severity and timestamp.
|
||||
func (h *AlertsHandler) sortAlerts(alerts []Alert) {
|
||||
// Simple bubble sort (small slice size expected)
|
||||
for i := 0; i < len(alerts)-1; i++ {
|
||||
for j := 0; j < len(alerts)-1-i; j++ {
|
||||
if h.compareAlerts(alerts[j], alerts[j+1]) > 0 {
|
||||
alerts[j], alerts[j+1] = alerts[j+1], alerts[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// compareAlerts returns negative if a < b (a should come first).
|
||||
func (h *AlertsHandler) compareAlerts(a, b Alert) int {
|
||||
// First compare by severity
|
||||
severityOrder := map[string]int{
|
||||
"critical": 0,
|
||||
"warning": 1,
|
||||
"info": 2,
|
||||
}
|
||||
|
||||
if severityOrder[a.Severity] != severityOrder[b.Severity] {
|
||||
return severityOrder[a.Severity] - severityOrder[b.Severity]
|
||||
}
|
||||
|
||||
// Same severity, compare by timestamp (newer first)
|
||||
if a.Timestamp > b.Timestamp {
|
||||
return -1
|
||||
}
|
||||
if a.Timestamp < b.Timestamp {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// formatFallMessage formats a fall event into a human-readable message.
|
||||
func (h *AlertsHandler) formatFallMessage(fall falldetect.FallEvent) string {
|
||||
if fall.Identity != "" {
|
||||
return fall.Identity + " in " + fall.ZoneName
|
||||
}
|
||||
return "Someone in " + fall.ZoneName
|
||||
}
|
||||
|
||||
// formatAnomalyMessage formats an anomaly into a human-readable message.
|
||||
func (h *AlertsHandler) formatAnomalyMessage(anomaly analytics.Anomaly) string {
|
||||
// Format the anomaly message based on its type and details
|
||||
return "Unusual activity detected"
|
||||
}
|
||||
|
||||
// handleAcknowledgeFall acknowledges a fall alert.
|
||||
// POST /api/fall/{id}/acknowledge
|
||||
//
|
||||
// This is a convenience route that redirects to the unified alert endpoint.
|
||||
// The alert ID is the fall ID (without the "fall-" prefix).
|
||||
func (h *AlertsHandler) handleAcknowledgeFall(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
http.Error(w, "fall ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the unified acknowledge handler with "fall-{id}" format
|
||||
h.handleAcknowledgeAlert(w, r)
|
||||
|
||||
// Override the path parameter to use the prefixed version
|
||||
// This is handled by the unified handler
|
||||
}
|
||||
|
||||
// handleAcknowledgeAnomaly acknowledges an anomaly alert.
|
||||
// POST /api/anomalies/{id}/acknowledge
|
||||
//
|
||||
// This is a convenience route that redirects to the unified alert endpoint.
|
||||
// The alert ID is the anomaly ID (without the "anomaly-" prefix).
|
||||
func (h *AlertsHandler) handleAcknowledgeAnomaly(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
http.Error(w, "anomaly ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the unified acknowledge handler with "anomaly-{id}" format
|
||||
h.handleAcknowledgeAlert(w, r)
|
||||
|
||||
// Override the path parameter to use the prefixed version
|
||||
// This is handled by the unified handler
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||
}
|
||||
|
|
@ -79,9 +79,11 @@ func (h *BriefingHandler) SetNotifyService(notifySvc briefing.NotifyService) {
|
|||
// RegisterRoutes registers the briefing API routes.
|
||||
func (h *BriefingHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/api/briefing", h.handleGetBriefing)
|
||||
r.Get("/api/briefing/today", h.handleGetTodayBriefing)
|
||||
r.Get("/api/briefing/{date}", h.handleGetBriefingByDate)
|
||||
r.Post("/api/briefing/generate", h.handleGenerateBriefing)
|
||||
r.Get("/api/briefing/latest", h.handleGetLatestBriefing)
|
||||
r.Post("/api/briefing/{id}/acknowledge", h.handleAcknowledgeBriefing)
|
||||
r.Get("/api/briefing/settings", h.handleGetSettings)
|
||||
r.Patch("/api/briefing/settings", h.handleUpdateSettings)
|
||||
r.Post("/api/briefing/test", h.handleTestNotification)
|
||||
|
|
@ -285,6 +287,62 @@ func (h *BriefingHandler) handleTestNotification(w http.ResponseWriter, r *http.
|
|||
})
|
||||
}
|
||||
|
||||
// handleGetTodayBriefing returns today's briefing, generating if needed.
|
||||
func (h *BriefingHandler) handleGetTodayBriefing(w http.ResponseWriter, r *http.Request) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
person := r.URL.Query().Get("person")
|
||||
|
||||
// First try to get existing briefing
|
||||
b, err := h.generator.Get(today, person)
|
||||
if err == nil {
|
||||
// Check if it's marked as delivered
|
||||
if !b.Delivered {
|
||||
// Mark as delivered on first fetch
|
||||
if err := h.generator.MarkDelivered(b.ID); err != nil {
|
||||
log.Printf("[WARN] Failed to mark briefing as delivered: %v", err)
|
||||
}
|
||||
b.Delivered = true
|
||||
}
|
||||
writeJSON(w, b)
|
||||
return
|
||||
}
|
||||
|
||||
// No briefing exists, generate one
|
||||
b, err = h.generator.Generate(today, person)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] Failed to generate today's briefing: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Save the new briefing
|
||||
if err := h.generator.Save(b); err != nil {
|
||||
log.Printf("[ERROR] Failed to save briefing: %v", err)
|
||||
}
|
||||
|
||||
writeJSON(w, b)
|
||||
}
|
||||
|
||||
// handleAcknowledgeBriefing marks a briefing as acknowledged by the user.
|
||||
func (h *BriefingHandler) handleAcknowledgeBriefing(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
http.Error(w, "Briefing ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark as acknowledged
|
||||
if err := h.generator.MarkAcknowledged(id); err != nil {
|
||||
log.Printf("[ERROR] Failed to acknowledge briefing: %v", err)
|
||||
http.Error(w, "Failed to acknowledge briefing", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[INFO] Briefing %s acknowledged", id)
|
||||
|
||||
writeJSON(w, map[string]string{"status": "acknowledged"})
|
||||
}
|
||||
|
||||
// GetGenerator returns the underlying briefing generator.
|
||||
func (h *BriefingHandler) GetGenerator() *briefing.Generator {
|
||||
return h.generator
|
||||
|
|
|
|||
|
|
@ -5,21 +5,28 @@ import (
|
|||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Generator produces morning briefings from sleep records, events, and system state.
|
||||
type Generator struct {
|
||||
db *sql.DB
|
||||
zoneProvider ZoneProvider
|
||||
personProvider PersonProvider
|
||||
db *sql.DB
|
||||
zoneProvider ZoneProvider
|
||||
personProvider PersonProvider
|
||||
predictionProvider PredictionProvider
|
||||
healthProvider HealthProvider
|
||||
healthProvider HealthProvider
|
||||
nodeProvider NodeInfoProvider
|
||||
weatherAPIURL string // Optional weather API URL
|
||||
quietHoursStart int // Hour when quiet hours start (default 22 = 10pm)
|
||||
quietHoursEnd int // Hour when quiet hours end (default 6 = 6am)
|
||||
}
|
||||
|
||||
// ZoneProvider provides zone information.
|
||||
|
|
@ -48,6 +55,13 @@ type HealthProvider interface {
|
|||
GetDetectionQuality() float64
|
||||
GetNodeCount() (online, total int)
|
||||
GetAccuracyDelta() (percent float64, feedbackCount int)
|
||||
GetNodeOfflineDuration(mac string) time.Duration
|
||||
}
|
||||
|
||||
// NodeInfoProvider provides node information for system health section.
|
||||
type NodeInfoProvider interface {
|
||||
GetNodeName(mac string) string
|
||||
GetAllNodeMACs() []string
|
||||
}
|
||||
|
||||
// NewGenerator creates a new briefing generator backed by the main DB.
|
||||
|
|
@ -57,7 +71,38 @@ func NewGenerator(dbPath string) (*Generator, error) {
|
|||
return nil, fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
return &Generator{db: db}, nil
|
||||
|
||||
// Check for weather API URL in settings
|
||||
var weatherURL string
|
||||
row := db.QueryRow("SELECT value_json FROM settings WHERE key = 'weather_api_url'")
|
||||
var weatherURLJSON sql.NullString
|
||||
if err := row.Scan(&weatherURLJSON); err == nil && weatherURLJSON.Valid {
|
||||
weatherURL = weatherURLJSON.String
|
||||
// Unwrap if it's JSON
|
||||
if strings.HasPrefix(weatherURL, `"`) {
|
||||
var url string
|
||||
json.Unmarshal([]byte(weatherURL), &url)
|
||||
weatherURL = url
|
||||
}
|
||||
}
|
||||
|
||||
return &Generator{
|
||||
db: db,
|
||||
weatherAPIURL: weatherURL,
|
||||
quietHoursStart: 22, // 10pm
|
||||
quietHoursEnd: 6, // 6am
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetQuietHours sets the quiet hours range for overnight events.
|
||||
func (g *Generator) SetQuietHours(start, end int) {
|
||||
g.quietHoursStart = start
|
||||
g.quietHoursEnd = end
|
||||
}
|
||||
|
||||
// SetWeatherAPIURL sets the weather API URL for weather section.
|
||||
func (g *Generator) SetWeatherAPIURL(url string) {
|
||||
g.weatherAPIURL = url
|
||||
}
|
||||
|
||||
// Close closes the DB connection.
|
||||
|
|
@ -73,20 +118,49 @@ func (g *Generator) SetProviders(z ZoneProvider, p PersonProvider, pr Prediction
|
|||
g.healthProvider = h
|
||||
}
|
||||
|
||||
// SetNodeInfoProvider sets the node info provider.
|
||||
func (g *Generator) SetNodeInfoProvider(n NodeInfoProvider) {
|
||||
g.nodeProvider = n
|
||||
}
|
||||
|
||||
// DailyBriefing is the primary struct for a morning briefing.
|
||||
// Alias for Briefing with additional delivery tracking fields.
|
||||
type DailyBriefing = Briefing
|
||||
|
||||
// BriefingSectionType defines the type of briefing section.
|
||||
type BriefingSectionType string
|
||||
|
||||
const (
|
||||
SectionTypeSleep BriefingSectionType = "sleep"
|
||||
SectionTypeOvernightEvents BriefingSectionType = "overnight_events"
|
||||
SectionTypeSystemHealth BriefingSectionType = "system_health"
|
||||
SectionTypePredictions BriefingSectionType = "predictions"
|
||||
SectionTypeWeather BriefingSectionType = "weather"
|
||||
SectionTypeAlert BriefingSectionType = "alert"
|
||||
SectionTypePeople BriefingSectionType = "people"
|
||||
SectionTypeAnomaly BriefingSectionType = "anomaly"
|
||||
SectionTypeLearning BriefingSectionType = "learning"
|
||||
)
|
||||
|
||||
// Briefing holds a generated morning briefing.
|
||||
type Briefing struct {
|
||||
Date string `json:"date"`
|
||||
Person string `json:"person,omitempty"`
|
||||
Content string `json:"content"`
|
||||
GeneratedAt int64 `json:"generated_at"`
|
||||
Sections []Section `json:"sections,omitempty"`
|
||||
ID string `json:"id"` // UUID
|
||||
Date string `json:"date"`
|
||||
Person string `json:"person,omitempty"`
|
||||
Content string `json:"content"`
|
||||
GeneratedAt int64 `json:"generated_at"`
|
||||
Sections []Section `json:"sections,omitempty"`
|
||||
Delivered bool `json:"delivered"` // Set true after first push
|
||||
Acknowledged bool `json:"acknowledged"` // Set true when user dismisses
|
||||
Metadata map[string]string `json:"metadata,omitempty"` // Additional metadata
|
||||
}
|
||||
|
||||
// Section represents a single section of the briefing.
|
||||
type Section struct {
|
||||
Type string `json:"type"` // "sleep", "people", "anomaly", "health", "prediction", "learning"
|
||||
Content string `json:"content"`
|
||||
Priority int `json:"priority"` // Higher = shown first
|
||||
Type BriefingSectionType `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Priority int `json:"priority"` // Higher = shown first
|
||||
Severity string `json:"severity,omitempty"` // For alerts: "info", "warning", "error"
|
||||
}
|
||||
|
||||
// Generate creates a morning briefing for the given date and person.
|
||||
|
|
@ -123,9 +197,9 @@ func (g *Generator) Generate(date string, person string) (*Briefing, error) {
|
|||
sections = append(sections, *peopleSection)
|
||||
}
|
||||
|
||||
// BLOCK 4 — Overnight anomalies
|
||||
if anomalySection := g.generateAnomalyBlock(nightStart, nightEnd, person); anomalySection != nil {
|
||||
sections = append(sections, *anomalySection)
|
||||
// BLOCK 4 — Overnight events (replaces anomaly block with more detailed filtering)
|
||||
if overnightSection := g.generateOvernightEventsBlock(nightStart, nightEnd, person); overnightSection != nil {
|
||||
sections = append(sections, *overnightSection)
|
||||
}
|
||||
|
||||
// BLOCK 5 — System health
|
||||
|
|
@ -133,12 +207,17 @@ func (g *Generator) Generate(date string, person string) (*Briefing, error) {
|
|||
sections = append(sections, *healthSection)
|
||||
}
|
||||
|
||||
// BLOCK 6 — Prediction hint
|
||||
// BLOCK 6 — Weather (optional)
|
||||
if weatherSection := g.generateWeatherBlock(); weatherSection != nil {
|
||||
sections = append(sections, *weatherSection)
|
||||
}
|
||||
|
||||
// BLOCK 7 — Prediction hint
|
||||
if predictionSection := g.generatePredictionBlock(person); predictionSection != nil {
|
||||
sections = append(sections, *predictionSection)
|
||||
}
|
||||
|
||||
// BLOCK 7 — Learning progress
|
||||
// BLOCK 8 — Learning progress
|
||||
if learningSection := g.generateLearningBlock(); learningSection != nil {
|
||||
sections = append(sections, *learningSection)
|
||||
}
|
||||
|
|
@ -169,11 +248,14 @@ func (g *Generator) Generate(date string, person string) (*Briefing, error) {
|
|||
content := strings.Join(contentParts, "\n\n")
|
||||
|
||||
return &Briefing{
|
||||
Date: date,
|
||||
Person: person,
|
||||
Content: content,
|
||||
GeneratedAt: time.Now().UnixMilli(),
|
||||
Sections: sections,
|
||||
ID: uuid.New().String(),
|
||||
Date: date,
|
||||
Person: person,
|
||||
Content: content,
|
||||
GeneratedAt: time.Now().UnixMilli(),
|
||||
Sections: sections,
|
||||
Delivered: false,
|
||||
Acknowledged: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -262,26 +344,26 @@ func (g *Generator) generateSleepBlock(date, person string) *Section {
|
|||
defer rows.Close()
|
||||
|
||||
var sleepRecords []struct {
|
||||
Duration sql.NullInt32
|
||||
OnsetLatency sql.NullFloat64
|
||||
Restlessness sql.NullFloat64
|
||||
BreathAvg sql.NullFloat64
|
||||
BreathReg sql.NullFloat64
|
||||
BreathAnomaly sql.NullBool
|
||||
BreathSamples sql.NullString
|
||||
Person sql.NullString
|
||||
Duration sql.NullInt32
|
||||
OnsetLatency sql.NullFloat64
|
||||
Restlessness sql.NullFloat64
|
||||
BreathAvg sql.NullFloat64
|
||||
BreathReg sql.NullFloat64
|
||||
BreathAnomaly sql.NullBool
|
||||
BreathSamples sql.NullString
|
||||
Person sql.NullString
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var r struct {
|
||||
Duration sql.NullInt32
|
||||
OnsetLatency sql.NullFloat64
|
||||
Restlessness sql.NullFloat64
|
||||
BreathAvg sql.NullFloat64
|
||||
BreathReg sql.NullFloat64
|
||||
BreathAnomaly sql.NullBool
|
||||
BreathSamples sql.NullString
|
||||
Person sql.NullString
|
||||
Duration sql.NullInt32
|
||||
OnsetLatency sql.NullFloat64
|
||||
Restlessness sql.NullFloat64
|
||||
BreathAvg sql.NullFloat64
|
||||
BreathReg sql.NullFloat64
|
||||
BreathAnomaly sql.NullBool
|
||||
BreathSamples sql.NullString
|
||||
Person sql.NullString
|
||||
}
|
||||
if err := rows.Scan(&r.Duration, &r.OnsetLatency, &r.Restlessness,
|
||||
&r.BreathAvg, &r.BreathReg, &r.BreathAnomaly, &r.BreathSamples, &r.Person); err != nil {
|
||||
|
|
@ -497,11 +579,10 @@ func (g *Generator) generateHealthBlock() *Section {
|
|||
quality := g.healthProvider.GetDetectionQuality()
|
||||
online, total := g.healthProvider.GetNodeCount()
|
||||
|
||||
// Skip if excellent and all nodes online
|
||||
if quality >= 90 && online == total {
|
||||
return nil
|
||||
}
|
||||
// Build content with node health details
|
||||
var contentParts []string
|
||||
|
||||
// Main health summary
|
||||
var health string
|
||||
switch {
|
||||
case quality >= 90:
|
||||
|
|
@ -514,11 +595,52 @@ func (g *Generator) generateHealthBlock() *Section {
|
|||
health = "Poor"
|
||||
}
|
||||
|
||||
content := fmt.Sprintf("System health: %s (%.0f%%). %d/%d nodes online.",
|
||||
health, quality, online, total)
|
||||
contentParts = append(contentParts, fmt.Sprintf("%d nodes healthy.", online))
|
||||
|
||||
// Check for offline nodes with duration
|
||||
if g.nodeProvider != nil {
|
||||
allMACs := g.nodeProvider.GetAllNodeMACs()
|
||||
for _, mac := range allMACs {
|
||||
// Get offline duration from health provider
|
||||
if g.healthProvider != nil {
|
||||
duration := g.healthProvider.GetNodeOfflineDuration(mac)
|
||||
if duration > 0 {
|
||||
name := g.nodeProvider.GetNodeName(mac)
|
||||
if name == "" {
|
||||
name = mac
|
||||
}
|
||||
|
||||
// Format duration
|
||||
durationH := int(duration.Hours())
|
||||
durationM := int(duration.Minutes()) % 60
|
||||
if durationH > 0 {
|
||||
if durationM > 0 {
|
||||
contentParts = append(contentParts, fmt.Sprintf("Node %s has been offline for %dh %dm.", name, durationH, durationM))
|
||||
} else {
|
||||
contentParts = append(contentParts, fmt.Sprintf("Node %s has been offline for %dh.", name, durationH))
|
||||
}
|
||||
} else if durationM > 0 {
|
||||
contentParts = append(contentParts, fmt.Sprintf("Node %s has been offline for %dmin.", name, durationM))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add detection quality if not excellent
|
||||
if quality < 90 {
|
||||
contentParts = append(contentParts, fmt.Sprintf("Detection quality: %.0f%%.", quality))
|
||||
}
|
||||
|
||||
// Skip if everything is healthy
|
||||
if len(contentParts) == 1 && online == total && quality >= 90 {
|
||||
return nil
|
||||
}
|
||||
|
||||
content := strings.Join(contentParts, " ")
|
||||
|
||||
return &Section{
|
||||
Type: "health",
|
||||
Type: SectionTypeSystemHealth,
|
||||
Content: content,
|
||||
Priority: 30,
|
||||
}
|
||||
|
|
@ -571,12 +693,184 @@ func (g *Generator) generateLearningBlock() *Section {
|
|||
}
|
||||
|
||||
return &Section{
|
||||
Type: "learning",
|
||||
Type: SectionTypeLearning,
|
||||
Content: content,
|
||||
Priority: 20,
|
||||
}
|
||||
}
|
||||
|
||||
// generateOvernightEventsBlock generates the overnight events section.
|
||||
// Filters for FallDetected, AnomalyDetected, NodeDisconnected events during quiet hours.
|
||||
func (g *Generator) generateOvernightEventsBlock(nightStart, nightEnd time.Time, person string) *Section {
|
||||
// Calculate quiet hours period
|
||||
quietStart := time.Date(nightEnd.Year(), nightEnd.Month(), nightEnd.Day(), g.quietHoursStart, 0, 0, 0, time.Local)
|
||||
quietEnd := time.Date(nightEnd.Year(), nightEnd.Month(), nightEnd.Day()+1, g.quietHoursEnd, 0, 0, 0, time.Local)
|
||||
|
||||
query := `SELECT type, zone, person, detail_json, timestamp_ms, severity
|
||||
FROM events
|
||||
WHERE timestamp_ms >= ? AND timestamp_ms < ?
|
||||
AND type IN ('fall_alert', 'anomaly', 'node_disconnected')
|
||||
AND severity IN ('warning', 'alert', 'critical')
|
||||
ORDER BY timestamp_ms ASC
|
||||
LIMIT 6`
|
||||
|
||||
args := []interface{}{quietStart.UnixMilli(), quietEnd.UnixMilli()}
|
||||
|
||||
if person != "" {
|
||||
query += ` AND person = ?`
|
||||
args = append(args, person)
|
||||
}
|
||||
|
||||
rows, err := g.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []struct {
|
||||
Type string
|
||||
Zone string
|
||||
Person string
|
||||
DetailJSON string
|
||||
Timestamp int64
|
||||
Severity string
|
||||
Acked bool
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var e struct {
|
||||
Type string
|
||||
Zone string
|
||||
Person string
|
||||
DetailJSON string
|
||||
Timestamp int64
|
||||
Severity string
|
||||
Acked bool
|
||||
}
|
||||
if err := rows.Scan(&e.Type, &e.Zone, &e.Person, &e.DetailJSON, &e.Timestamp, &e.Severity); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if acknowledged from detail_json
|
||||
var detail map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(e.DetailJSON), &detail); err == nil {
|
||||
if acked, ok := detail["acknowledged"].(bool); ok {
|
||||
e.Acked = acked
|
||||
}
|
||||
}
|
||||
|
||||
events = append(events, e)
|
||||
}
|
||||
|
||||
if len(events) == 0 {
|
||||
return &Section{
|
||||
Type: SectionTypeOvernightEvents,
|
||||
Content: "No incidents overnight.",
|
||||
Priority: 40,
|
||||
}
|
||||
}
|
||||
|
||||
var contentParts []string
|
||||
for i, e := range events {
|
||||
var eventStr strings.Builder
|
||||
timeStr := time.Unix(0, e.Timestamp*1e6).Format("3:04pm")
|
||||
|
||||
switch e.Type {
|
||||
case "fall_alert":
|
||||
eventStr.WriteString(fmt.Sprintf("Possible fall detected at %s", timeStr))
|
||||
if e.Person != "" {
|
||||
eventStr.WriteString(fmt.Sprintf(" for %s", e.Person))
|
||||
}
|
||||
if e.Zone != "" {
|
||||
eventStr.WriteString(fmt.Sprintf(" in %s", e.Zone))
|
||||
}
|
||||
if e.Acked {
|
||||
eventStr.WriteString(" (acknowledged)")
|
||||
}
|
||||
|
||||
case "anomaly":
|
||||
eventStr.WriteString(fmt.Sprintf("Anomaly detected at %s", timeStr))
|
||||
if e.Zone != "" {
|
||||
eventStr.WriteString(fmt.Sprintf(" in %s", e.Zone))
|
||||
}
|
||||
if e.Acked {
|
||||
eventStr.WriteString(" (acknowledged)")
|
||||
}
|
||||
|
||||
case "node_disconnected":
|
||||
eventStr.WriteString(fmt.Sprintf("Node %s went offline at %s", e.Zone, timeStr))
|
||||
// Try to get reconnection time
|
||||
var reconnectTime sql.NullInt64
|
||||
reconnectQuery := `SELECT timestamp_ms FROM events
|
||||
WHERE type = 'node_connected' AND zone = ?
|
||||
AND timestamp_ms > ? ORDER BY timestamp_ms ASC LIMIT 1`
|
||||
err := g.db.QueryRow(reconnectQuery, e.Zone, e.Timestamp).Scan(&reconnectTime)
|
||||
if err == nil && reconnectTime.Valid {
|
||||
reconnectStr := time.Unix(0, reconnectTime.Int64*1e6).Format("3:04pm")
|
||||
eventStr.WriteString(fmt.Sprintf(" and reconnected at %s", reconnectStr))
|
||||
}
|
||||
}
|
||||
|
||||
contentParts = append(contentParts, eventStr.String())
|
||||
|
||||
// Limit to 5 events
|
||||
if i >= 4 && len(events) > 5 {
|
||||
contentParts = append(contentParts, fmt.Sprintf("...and %d more events.", len(events)-5))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
content := strings.Join(contentParts, ". ")
|
||||
if len(contentParts) > 1 {
|
||||
content += "."
|
||||
}
|
||||
|
||||
return &Section{
|
||||
Type: SectionTypeOvernightEvents,
|
||||
Content: content,
|
||||
Priority: 75,
|
||||
}
|
||||
}
|
||||
|
||||
// generateWeatherBlock generates the optional weather section.
|
||||
func (g *Generator) generateWeatherBlock() *Section {
|
||||
if g.weatherAPIURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fetch weather from wttr.in
|
||||
// Format: GET https://wttr.in/{location}?format=%t+%C
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(g.weatherAPIURL)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to fetch weather: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Printf("[WARN] Weather API returned status %d", resp.StatusCode)
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to read weather response: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
weather := strings.TrimSpace(string(body))
|
||||
if weather == "" || weather == "Unknown" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &Section{
|
||||
Type: SectionTypeWeather,
|
||||
Content: fmt.Sprintf("Outside: %s", weather),
|
||||
Priority: 15,
|
||||
}
|
||||
}
|
||||
|
||||
// getAverageSleepDuration calculates average sleep duration over the past 7 days.
|
||||
func (g *Generator) getAverageSleepDuration(person string) int {
|
||||
query := `SELECT AVG(duration_min) FROM sleep_records
|
||||
|
|
@ -762,3 +1056,117 @@ func (g *Generator) ShouldGenerate(date string, person string) bool {
|
|||
err := g.db.QueryRow(query, args...).Scan(&count)
|
||||
return err == nil && count == 0
|
||||
}
|
||||
|
||||
// MarkDelivered marks a briefing as delivered.
|
||||
func (g *Generator) MarkDelivered(id string) error {
|
||||
// Check if delivered column exists
|
||||
var deliveredColExists bool
|
||||
err := g.db.QueryRow(`
|
||||
SELECT COUNT(*) > 0 FROM pragma_table_info('briefings') WHERE name = 'delivered'
|
||||
`).Scan(&deliveredColExists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check delivered column: %w", err)
|
||||
}
|
||||
|
||||
if !deliveredColExists {
|
||||
// Column doesn't exist yet, skip
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = g.db.Exec(`UPDATE briefings SET delivered = 1 WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkAcknowledged marks a briefing as acknowledged by the user.
|
||||
func (g *Generator) MarkAcknowledged(id string) error {
|
||||
// Check if acknowledged column exists
|
||||
var acknowledgedColExists bool
|
||||
err := g.db.QueryRow(`
|
||||
SELECT COUNT(*) > 0 FROM pragma_table_info('briefings') WHERE name = 'acknowledged'
|
||||
`).Scan(&acknowledgedColExists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check acknowledged column: %w", err)
|
||||
}
|
||||
|
||||
if !acknowledgedColExists {
|
||||
// Column doesn't exist yet, skip
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = g.db.Exec(`UPDATE briefings SET acknowledged = 1 WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetTodayBriefing returns today's briefing as a map for the dashboard.
|
||||
func (g *Generator) GetTodayBriefing() (map[string]interface{}, error) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
b, err := g.Get(today, "")
|
||||
if err != nil {
|
||||
// No briefing exists, try generating one
|
||||
b, err = g.Generate(today, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Save the new briefing
|
||||
if err := g.Save(b); err != nil {
|
||||
log.Printf("[WARN] Failed to save briefing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to map for JSON marshaling
|
||||
result := map[string]interface{}{
|
||||
"id": b.ID,
|
||||
"date": b.Date,
|
||||
"content": b.Content,
|
||||
"generated_at": b.GeneratedAt,
|
||||
"delivered": b.Delivered,
|
||||
"acknowledged": b.Acknowledged,
|
||||
}
|
||||
|
||||
if len(b.Sections) > 0 {
|
||||
result["sections"] = b.Sections
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ShouldPushBriefing checks if the briefing should be pushed to clients.
|
||||
// Returns true if it's after 6am and the briefing hasn't been delivered yet.
|
||||
func (g *Generator) ShouldPushBriefing() bool {
|
||||
now := time.Now()
|
||||
hour := now.Hour()
|
||||
|
||||
// Only push after 6am
|
||||
if hour < 6 {
|
||||
return false
|
||||
}
|
||||
|
||||
today := now.Format("2006-01-02")
|
||||
|
||||
// Check if a briefing exists for today
|
||||
var count int
|
||||
err := g.db.QueryRow(`SELECT COUNT(*) FROM briefings WHERE date = ?`, today).Scan(&count)
|
||||
if err != nil || count == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if delivered column exists
|
||||
var deliveredColExists bool
|
||||
err = g.db.QueryRow(`
|
||||
SELECT COUNT(*) > 0 FROM pragma_table_info('briefings') WHERE name = 'delivered'
|
||||
`).Scan(&deliveredColExists)
|
||||
if err != nil || !deliveredColExists {
|
||||
// If column doesn't exist, assume not delivered
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if already delivered
|
||||
var delivered int
|
||||
err = g.db.QueryRow(`SELECT delivered FROM briefings WHERE date = ?`, today).Scan(&delivered)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return delivered == 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import (
|
|||
|
||||
// mockZoneProvider implements ZoneProvider for testing.
|
||||
type mockZoneProvider struct {
|
||||
zones map[int]string
|
||||
zones map[int]string
|
||||
occupancy map[int]int
|
||||
people map[int][]string
|
||||
people map[int][]string
|
||||
}
|
||||
|
||||
func (m *mockZoneProvider) GetZoneName(id int) string {
|
||||
|
|
@ -69,9 +69,9 @@ func (m *mockPersonProvider) GetPersonZone(person string) string {
|
|||
|
||||
// mockPredictionProvider implements PredictionProvider for testing.
|
||||
type mockPredictionProvider struct {
|
||||
predictions map[string]mockPrediction
|
||||
predictions map[string]mockPrediction
|
||||
daysComplete map[string]int
|
||||
modelReady map[string]bool
|
||||
modelReady map[string]bool
|
||||
}
|
||||
|
||||
type mockPrediction struct {
|
||||
|
|
@ -106,9 +106,9 @@ func (m *mockPredictionProvider) IsModelReady(person string) bool {
|
|||
|
||||
// mockHealthProvider implements HealthProvider for testing.
|
||||
type mockHealthProvider struct {
|
||||
quality float64
|
||||
online int
|
||||
total int
|
||||
quality float64
|
||||
online int
|
||||
total int
|
||||
accuracyDelta float64
|
||||
feedbackCount int
|
||||
}
|
||||
|
|
|
|||
109
mothership/internal/briefing/dashboard_adapter.go
Normal file
109
mothership/internal/briefing/dashboard_adapter.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// Package briefing provides dashboard adapter for morning briefing.
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"log"
|
||||
)
|
||||
|
||||
// DashboardAdapter adapts the Generator to the dashboard BriefingProvider interface.
|
||||
type DashboardAdapter struct {
|
||||
generator *Generator
|
||||
}
|
||||
|
||||
// NewDashboardAdapter creates a new dashboard adapter.
|
||||
func NewDashboardAdapter(gen *Generator) *DashboardAdapter {
|
||||
return &DashboardAdapter{generator: gen}
|
||||
}
|
||||
|
||||
// GetTodayBriefing returns today's briefing as a map for the dashboard.
|
||||
func (a *DashboardAdapter) GetTodayBriefing() (map[string]interface{}, error) {
|
||||
return a.generator.GetTodayBriefing()
|
||||
}
|
||||
|
||||
// MarkDelivered marks a briefing as delivered.
|
||||
func (a *DashboardAdapter) MarkDelivered(id string) error {
|
||||
return a.generator.MarkDelivered(id)
|
||||
}
|
||||
|
||||
// ShouldPushBriefing checks if the briefing should be pushed to clients.
|
||||
func (a *DashboardAdapter) ShouldPushBriefing() bool {
|
||||
return a.generator.ShouldPushBriefing()
|
||||
}
|
||||
|
||||
// SetQuietHours sets the quiet hours range for overnight events.
|
||||
func (a *DashboardAdapter) SetQuietHours(start, end int) {
|
||||
a.generator.SetQuietHours(start, end)
|
||||
}
|
||||
|
||||
// SetWeatherAPIURL sets the weather API URL for weather section.
|
||||
func (a *DashboardAdapter) SetWeatherAPIURL(url string) {
|
||||
a.generator.SetWeatherAPIURL(url)
|
||||
}
|
||||
|
||||
// SetProviders sets the provider interfaces for briefing generation.
|
||||
func (a *DashboardAdapter) SetProviders(z ZoneProvider, p PersonProvider, pr PredictionProvider, hp HealthProvider) {
|
||||
a.generator.SetProviders(z, p, pr, hp)
|
||||
}
|
||||
|
||||
// SetNodeInfoProvider sets the node info provider.
|
||||
func (a *DashboardAdapter) SetNodeInfoProvider(n NodeInfoProvider) {
|
||||
a.generator.SetNodeInfoProvider(n)
|
||||
}
|
||||
|
||||
// Close closes the underlying generator.
|
||||
func (a *DashboardAdapter) Close() error {
|
||||
return a.generator.Close()
|
||||
}
|
||||
|
||||
// GetGenerator returns the underlying generator for direct access.
|
||||
func (a *DashboardAdapter) GetGenerator() *Generator {
|
||||
return a.generator
|
||||
}
|
||||
|
||||
// Generate creates a morning briefing for the given date and person.
|
||||
func (a *DashboardAdapter) Generate(date, person string) (*Briefing, error) {
|
||||
return a.generator.Generate(date, person)
|
||||
}
|
||||
|
||||
// Save persists a briefing to the database.
|
||||
func (a *DashboardAdapter) Save(b *Briefing) error {
|
||||
return a.generator.Save(b)
|
||||
}
|
||||
|
||||
// Get retrieves a previously generated briefing by date and optional person.
|
||||
func (a *DashboardAdapter) Get(date, person string) (*Briefing, error) {
|
||||
return a.generator.Get(date, person)
|
||||
}
|
||||
|
||||
// GetLatest retrieves the most recent briefing.
|
||||
func (a *DashboardAdapter) GetLatest() (*Briefing, error) {
|
||||
return a.generator.GetLatest()
|
||||
}
|
||||
|
||||
// ShouldGenerate checks if a briefing should be generated for the given date.
|
||||
func (a *DashboardAdapter) ShouldGenerate(date, person string) bool {
|
||||
return a.generator.ShouldGenerate(date, person)
|
||||
}
|
||||
|
||||
// MarkAcknowledged marks a briefing as acknowledged by the user.
|
||||
func (a *DashboardAdapter) MarkAcknowledged(id string) error {
|
||||
return a.generator.MarkAcknowledged(id)
|
||||
}
|
||||
|
||||
// GetBriefingForAPI returns a briefing formatted for API response with all fields.
|
||||
func (a *DashboardAdapter) GetBriefingForAPI(date string, person string) (*Briefing, error) {
|
||||
b, err := a.generator.Get(date, person)
|
||||
if err != nil {
|
||||
// Try generating if not found
|
||||
b, err = a.generator.Generate(date, person)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] Failed to generate briefing for %s: %v", date, err)
|
||||
return nil, err
|
||||
}
|
||||
// Save the new briefing
|
||||
if err := a.generator.Save(b); err != nil {
|
||||
log.Printf("[ERROR] Failed to save briefing for %s: %v", date, err)
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
|
@ -11,13 +11,13 @@ import (
|
|||
|
||||
// Scheduler handles automatic briefing generation and push notifications.
|
||||
type Scheduler struct {
|
||||
generator *Generator
|
||||
notifyService NotifyService
|
||||
mu sync.RWMutex
|
||||
config SchedulerConfig
|
||||
ticker *time.Ticker
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
generator *Generator
|
||||
notifyService NotifyService
|
||||
mu sync.RWMutex
|
||||
config SchedulerConfig
|
||||
ticker *time.Ticker
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
// SchedulerConfig holds scheduling configuration.
|
||||
|
|
@ -197,7 +197,7 @@ func (s *Scheduler) checkAndGenerate() {
|
|||
func (s *Scheduler) sendNotification(b *Briefing) {
|
||||
notification := Notification{
|
||||
Title: "Morning Briefing",
|
||||
Body: s.formatNotificationBody(b),
|
||||
Body: s.formatNotificationBody(b),
|
||||
Priority: 1, // Low priority for morning briefings
|
||||
Tags: []string{"briefing", "morning"},
|
||||
Timestamp: time.Now(),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ type Hub struct {
|
|||
eventStore EventStore
|
||||
securityState SecurityStateProvider
|
||||
sleepState SleepStateProvider
|
||||
briefingProvider BriefingProvider
|
||||
|
||||
// Pending events buffer — events accumulated between 10 Hz delta ticks.
|
||||
pendingEvents []map[string]interface{}
|
||||
|
|
@ -159,6 +160,13 @@ type SleepStateProvider interface {
|
|||
ShouldPushMorningSummary() (bool, map[string]interface{})
|
||||
}
|
||||
|
||||
// BriefingProvider provides morning briefing functionality.
|
||||
type BriefingProvider interface {
|
||||
GetTodayBriefing() (map[string]interface{}, error)
|
||||
MarkDelivered(id string) error
|
||||
ShouldPushBriefing() bool
|
||||
}
|
||||
|
||||
// ReplayHandler is the interface for replay engine operations.
|
||||
type ReplayHandler interface {
|
||||
Seek(targetMS int64) error
|
||||
|
|
@ -257,6 +265,13 @@ func (h *Hub) SetSleepState(state SleepStateProvider) {
|
|||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetBriefingProvider sets the briefing provider for morning briefing push.
|
||||
func (h *Hub) SetBriefingProvider(provider BriefingProvider) {
|
||||
h.mu.Lock()
|
||||
h.briefingProvider = provider
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// Run starts the hub's main loop.
|
||||
// The 10 Hz delta tick replaces the old 5 s state / 500 ms presence broadcasts.
|
||||
// BLE scan results are broadcast every 5 s as a separate typed message.
|
||||
|
|
@ -297,6 +312,9 @@ func (h *Hub) Run() {
|
|||
h.mu.Unlock()
|
||||
log.Printf("[INFO] Dashboard client connected (total: %d)", len(h.clients))
|
||||
|
||||
// Push morning briefing on first connection after 6am
|
||||
h.pushBriefingToClient(client)
|
||||
|
||||
case client := <-h.unregister:
|
||||
h.mu.Lock()
|
||||
if _, ok := h.clients[client]; ok {
|
||||
|
|
@ -1152,6 +1170,54 @@ func (h *Hub) BroadcastMorningSummary(summary map[string]interface{}) {
|
|||
h.Broadcast(data)
|
||||
}
|
||||
|
||||
// pushBriefingToClient pushes the morning briefing to a specific client on first connection.
|
||||
func (h *Hub) pushBriefingToClient(client *Client) {
|
||||
h.mu.RLock()
|
||||
provider := h.briefingProvider
|
||||
h.mu.RUnlock()
|
||||
|
||||
if provider == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if briefing should be pushed (after 6am and not yet delivered)
|
||||
if !provider.ShouldPushBriefing() {
|
||||
return
|
||||
}
|
||||
|
||||
// Get today's briefing
|
||||
briefing, err := provider.GetTodayBriefing()
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to get today's briefing: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark as delivered
|
||||
if id, ok := briefing["id"].(string); ok {
|
||||
if err := provider.MarkDelivered(id); err != nil {
|
||||
log.Printf("[WARN] Failed to mark briefing as delivered: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Send briefing to client
|
||||
msg := map[string]interface{}{
|
||||
"type": "morning_briefing",
|
||||
"briefing": briefing,
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to marshal briefing: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case client.send <- data:
|
||||
log.Printf("[INFO] Morning briefing pushed to client")
|
||||
default:
|
||||
log.Printf("[WARN] Briefing dropped for new client (buffer full)")
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastReplayBlobs broadcasts replay blob updates to all dashboard clients.
|
||||
// This implements the replay.BlobBroadcaster interface for time-travel debugging.
|
||||
func (h *Hub) BroadcastReplayBlobs(blobs []replay.BlobUpdate, timestampMS int64) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue