ci: set VERSION to 0.1.0 for image build
This commit is contained in:
parent
0f5db551e5
commit
c75428d488
16 changed files with 1765 additions and 114 deletions
|
|
@ -38,7 +38,7 @@ echo "╔═══════════════════════
|
|||
echo "║ Spaxel Marathon — GLM-5 via ZAI Proxy ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo " Model: GLM-5 (via zai-proxy-apexalgo)"
|
||||
echo " Model: GLM-5 (via zai-proxy-hub)"
|
||||
echo " Subagents: GLM-5-Turbo"
|
||||
echo " Instruction: $INSTRUCTION_FILE"
|
||||
echo " Session: $SESSION_NAME"
|
||||
|
|
@ -47,8 +47,9 @@ echo ""
|
|||
|
||||
# Create tmux session with GLM-5 env vars and run the marathon launcher
|
||||
tmux new-session -d -s "$SESSION_NAME" -c "/home/coding/spaxel" \
|
||||
"export ANTHROPIC_BASE_URL='http://zai-proxy-apexalgo.tail1b1987.ts.net:8080' && \
|
||||
"export ANTHROPIC_BASE_URL='https://zai-proxy-mcp-ardenone-hub-ts.ardenone.com:8444' && \
|
||||
export ANTHROPIC_AUTH_TOKEN='proxy-handles-auth' && \
|
||||
export NODE_TLS_REJECT_UNAUTHORIZED=0 && \
|
||||
export ANTHROPIC_MODEL='glm-5' && \
|
||||
export ANTHROPIC_DEFAULT_OPUS_MODEL='glm-5' && \
|
||||
export ANTHROPIC_DEFAULT_SONNET_MODEL='glm-5-turbo' && \
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.1.4
|
||||
0.1.0
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ const Viz3D = (function () {
|
|||
|
||||
// Update flow arrow animation
|
||||
updateFlowAnimation(dt);
|
||||
|
||||
// Update anomaly zone pulse
|
||||
updateAnomalyPulse(dt);
|
||||
}
|
||||
|
||||
// ── room bounds ───────────────────────────────────────────────────────────
|
||||
|
|
@ -625,6 +628,231 @@ const Viz3D = (function () {
|
|||
});
|
||||
}
|
||||
|
||||
// ── identity label rendering ────────────────────────────────────────────────
|
||||
|
||||
let _identityLabels = new Map(); // blobId → THREE.Sprite (text label)
|
||||
let _bleOnlyTracks = new Map(); // personID → { group, pillar, circle }
|
||||
|
||||
/**
|
||||
* Create a text sprite with the given text and color.
|
||||
* @param {string} text - Label text
|
||||
* @param {string} color - CSS color string (e.g., '#3b82f6')
|
||||
* @returns {THREE.Sprite}
|
||||
*/
|
||||
function _createTextSprite(text, color) {
|
||||
var canvas = document.createElement('canvas');
|
||||
var ctx = canvas.getContext('2d');
|
||||
canvas.width = 256;
|
||||
canvas.height = 64;
|
||||
|
||||
// Draw background with rounded corners
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(4, 4, canvas.width - 8, canvas.height - 8, 8);
|
||||
ctx.fill();
|
||||
|
||||
// Draw border in person color
|
||||
ctx.strokeStyle = color || '#4fc3f7';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(4, 4, canvas.width - 8, canvas.height - 8, 8);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw text
|
||||
ctx.fillStyle = color || '#ffffff';
|
||||
ctx.font = 'bold 28px Arial, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
|
||||
|
||||
var texture = new THREE.CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
|
||||
var material = new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthTest: false
|
||||
});
|
||||
|
||||
var sprite = new THREE.Sprite(material);
|
||||
sprite.scale.set(1.2, 0.3, 1);
|
||||
sprite.position.set(0, 2.0, 0); // Above humanoid head
|
||||
|
||||
return sprite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a BLE-only placeholder track visualization.
|
||||
* These are shown when a BLE device is heard but no CSI blob is nearby.
|
||||
* @param {Object} match - IdentityMatch with triangulation position
|
||||
* @returns {Object} Three.js objects { group, pillar, circle }
|
||||
*/
|
||||
function _createBLEOnlyTrack(match) {
|
||||
var group = new THREE.Group();
|
||||
group.userData.personId = match.person_id;
|
||||
group.userData.isBLEOnly = true;
|
||||
|
||||
// Dashed circle on floor to indicate BLE-only position
|
||||
var circleGeo = new THREE.RingGeometry(0.25, 0.35, 32);
|
||||
var circleMat = new THREE.MeshBasicMaterial({
|
||||
color: match.person_color ? parseInt(match.person_color.replace('#', '0x')) : 0x4fc3f7,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
var circle = new THREE.Mesh(circleGeo, circleMat);
|
||||
circle.rotation.x = -Math.PI / 2;
|
||||
circle.position.y = 0.02;
|
||||
group.add(circle);
|
||||
|
||||
// Vertical dashed pillar
|
||||
var pillarGeo = new THREE.BufferGeometry();
|
||||
pillarGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array([0, 0, 0, 0, 2.0, 0]), 3));
|
||||
var pillarMat = new THREE.LineDashedMaterial({
|
||||
color: 0x888888,
|
||||
dashSize: 0.1,
|
||||
gapSize: 0.05,
|
||||
transparent: true,
|
||||
opacity: 0.4
|
||||
});
|
||||
var pillar = new THREE.Line(pillarGeo, pillarMat);
|
||||
pillar.computeLineDistances();
|
||||
group.add(pillar);
|
||||
|
||||
// Position from triangulation
|
||||
var pos = match.triangulation_pos || { x: 0, y: 0, z: 0 };
|
||||
group.position.set(pos.x, 0, pos.z);
|
||||
|
||||
// Add identity label
|
||||
if (match.person_name) {
|
||||
var label = _createTextSprite(match.person_name, match.person_color);
|
||||
label.position.set(0, 1.2, 0);
|
||||
group.add(label);
|
||||
group.userData.label = label;
|
||||
}
|
||||
|
||||
_scene.add(group);
|
||||
|
||||
return { group: group, pillar: pillar, circle: circle };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update identity labels on tracked blobs.
|
||||
* Called from BLEPanel when matches are updated.
|
||||
* @param {Array} matches - Array of IdentityMatch objects
|
||||
*/
|
||||
function updateIdentities(matches) {
|
||||
if (!matches) matches = [];
|
||||
|
||||
var matchesByBlobId = new Map();
|
||||
matches.forEach(function(m) {
|
||||
if (m.blob_id > 0) {
|
||||
matchesByBlobId.set(m.blob_id, m);
|
||||
}
|
||||
});
|
||||
|
||||
// Update or create identity labels on existing blobs
|
||||
_blobs3D.forEach(function(obj, blobId) {
|
||||
var match = matchesByBlobId.get(blobId);
|
||||
|
||||
// Remove existing label if any
|
||||
if (obj.identityLabel) {
|
||||
obj.group.remove(obj.identityLabel);
|
||||
obj.identityLabel = null;
|
||||
}
|
||||
|
||||
if (match && match.person_name && match.confidence >= 0.6) {
|
||||
// Create new label
|
||||
var label = _createTextSprite(match.person_name, match.person_color);
|
||||
label.position.set(0, 2.0, 0);
|
||||
obj.group.add(label);
|
||||
obj.identityLabel = label;
|
||||
|
||||
// Update humanoid color if available
|
||||
if (match.person_color && obj.humanoid && obj.humanoid.mesh) {
|
||||
var color = parseInt(match.person_color.replace('#', '0x'));
|
||||
obj.humanoid.mesh.material.color.setHex(color);
|
||||
obj.humanoid.mesh.material.emissive.setHex(color);
|
||||
obj.humanoid.mesh.material.emissiveIntensity = 0.15;
|
||||
}
|
||||
|
||||
// Store identity info
|
||||
obj.identity = match;
|
||||
} else {
|
||||
// Reset to default color
|
||||
var ci = blobId % BLOB_COLORS.length;
|
||||
if (obj.humanoid && obj.humanoid.mesh) {
|
||||
obj.humanoid.mesh.material.color.setHex(BLOB_COLORS[ci]);
|
||||
obj.humanoid.mesh.material.emissive = new THREE.Color(BLOB_COLORS[ci]);
|
||||
obj.humanoid.mesh.material.emissiveIntensity = 0;
|
||||
}
|
||||
obj.identity = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle BLE-only tracks (devices heard but no CSI blob nearby)
|
||||
var seenBLEOnly = new Set();
|
||||
|
||||
matches.forEach(function(match) {
|
||||
if (match.is_ble_only && match.person_id) {
|
||||
seenBLEOnly.add(match.person_id);
|
||||
|
||||
var existing = _bleOnlyTracks.get(match.person_id);
|
||||
var pos = match.triangulation_pos || { x: 0, y: 0, z: 0 };
|
||||
|
||||
if (existing) {
|
||||
// Update position
|
||||
existing.group.position.set(pos.x, 0, pos.z);
|
||||
existing.group.visible = true;
|
||||
} else {
|
||||
// Create new BLE-only track
|
||||
var track = _createBLEOnlyTrack(match);
|
||||
_bleOnlyTracks.set(match.person_id, track);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Hide BLE-only tracks not in current matches
|
||||
_bleOnlyTracks.forEach(function(track, personId) {
|
||||
if (!seenBLEOnly.has(personId)) {
|
||||
track.group.visible = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get identity info for a blob.
|
||||
* @param {number} blobId
|
||||
* @returns {Object|null} Identity match or null
|
||||
*/
|
||||
function getBlobIdentity(blobId) {
|
||||
var obj = _blobs3D.get(blobId);
|
||||
return obj ? obj.identity : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all identity labels.
|
||||
*/
|
||||
function clearIdentities() {
|
||||
_identityLabels.forEach(function(label) {
|
||||
if (label.parent) label.parent.remove(label);
|
||||
});
|
||||
_identityLabels.clear();
|
||||
|
||||
_bleOnlyTracks.forEach(function(track) {
|
||||
_scene.remove(track.group);
|
||||
});
|
||||
_bleOnlyTracks.clear();
|
||||
|
||||
_blobs3D.forEach(function(obj) {
|
||||
if (obj.identityLabel) {
|
||||
obj.group.remove(obj.identityLabel);
|
||||
obj.identityLabel = null;
|
||||
}
|
||||
obj.identity = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ── blob interaction (feedback buttons) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -1376,6 +1604,131 @@ const Viz3D = (function () {
|
|||
};
|
||||
}
|
||||
|
||||
// ── Anomaly Zone Pulsing ─────────────────────────────────────────────────────
|
||||
|
||||
let _anomalyZones = []; // Array of zone IDs with active anomalies
|
||||
let _anomalyMeshes = new Map(); // zoneID -> THREE.Mesh (pulsing overlay)
|
||||
let _anomalyPulseTime = 0;
|
||||
|
||||
/**
|
||||
* Set which zones have active anomalies (will pulse red).
|
||||
* @param {Array} zoneIDs - Array of zone ID strings
|
||||
*/
|
||||
function setAnomalyZones(zoneIDs) {
|
||||
_anomalyZones = zoneIDs || [];
|
||||
|
||||
// Remove meshes for zones no longer anomalous
|
||||
_anomalyMeshes.forEach(function(mesh, zoneID) {
|
||||
if (_anomalyZones.indexOf(zoneID) === -1) {
|
||||
_scene.remove(mesh);
|
||||
mesh.geometry.dispose();
|
||||
mesh.material.dispose();
|
||||
_anomalyMeshes.delete(zoneID);
|
||||
}
|
||||
});
|
||||
|
||||
// Add meshes for new anomalous zones
|
||||
_anomalyZones.forEach(function(zoneID) {
|
||||
if (!_anomalyMeshes.has(zoneID)) {
|
||||
// Create a pulsing red overlay for this zone
|
||||
// Default to center of room if zone position unknown
|
||||
var cx = _room ? (_room.origin_x || 0) + _room.width / 2 : 3;
|
||||
var cz = _room ? (_room.origin_z || 0) + _room.depth / 2 : 2.5;
|
||||
|
||||
// Try to get zone-specific position from zone provider
|
||||
// For now, use a 1x1m red overlay at the zone center
|
||||
var geo = new THREE.PlaneGeometry(1.5, 1.5);
|
||||
var mat = new THREE.MeshBasicMaterial({
|
||||
color: 0xef4444,
|
||||
transparent: true,
|
||||
opacity: 0.4,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false
|
||||
});
|
||||
|
||||
var mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.position.set(cx, 0.03, cz);
|
||||
mesh.userData.zoneID = zoneID;
|
||||
|
||||
_scene.add(mesh);
|
||||
_anomalyMeshes.set(zoneID, mesh);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Viz3D] Anomaly zones updated:', _anomalyZones);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update anomaly pulse animation (called from main update loop).
|
||||
* @param {number} dt - Delta time in seconds
|
||||
*/
|
||||
function updateAnomalyPulse(dt) {
|
||||
if (_anomalyMeshes.size === 0) return;
|
||||
|
||||
_anomalyPulseTime += dt;
|
||||
// 1.5 second pulse cycle
|
||||
var phase = (_anomalyPulseTime % 1.5) / 1.5;
|
||||
// Opacity oscillates: 0.2 -> 0.6 -> 0.2
|
||||
var opacity = 0.2 + 0.4 * (1 - Math.abs(phase - 0.5) * 2);
|
||||
|
||||
_anomalyMeshes.forEach(function(mesh) {
|
||||
mesh.material.opacity = opacity;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the camera on a specific zone.
|
||||
* @param {string} zoneID - The zone ID to focus on
|
||||
*/
|
||||
function focusOnZone(zoneID) {
|
||||
if (!_camera || !_controls) return;
|
||||
|
||||
// Get zone position from anomaly mesh if available
|
||||
var mesh = _anomalyMeshes.get(zoneID);
|
||||
if (mesh) {
|
||||
var pos = mesh.position;
|
||||
_camera.position.set(pos.x + 2, 2.0, pos.z + 3);
|
||||
_controls.target.set(pos.x, 0.5, pos.z);
|
||||
_controls.update();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: focus on room center
|
||||
var cx = _room ? (_room.origin_x || 0) + _room.width / 2 : 3;
|
||||
var cz = _room ? (_room.origin_z || 0) + _room.depth / 2 : 2.5;
|
||||
_camera.position.set(cx + 2, 2.0, cz + 3);
|
||||
_controls.target.set(cx, 0.5, cz);
|
||||
_controls.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the camera on a specific position.
|
||||
* @param {number} x - X coordinate
|
||||
* @param {number} y - Y coordinate (height)
|
||||
* @param {number} z - Z coordinate
|
||||
*/
|
||||
function focusOnPosition(x, y, z) {
|
||||
if (!_camera || !_controls) return;
|
||||
|
||||
_camera.position.set(x + 2, Math.max(y + 1, 2.0), z + 3);
|
||||
_controls.target.set(x, y, z);
|
||||
_controls.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all anomaly zone overlays.
|
||||
*/
|
||||
function clearAnomalyZones() {
|
||||
_anomalyMeshes.forEach(function(mesh) {
|
||||
_scene.remove(mesh);
|
||||
mesh.geometry.dispose();
|
||||
mesh.material.dispose();
|
||||
});
|
||||
_anomalyMeshes.clear();
|
||||
_anomalyZones = [];
|
||||
}
|
||||
|
||||
// ── public API ────────────────────────────────────────────────────────────
|
||||
return {
|
||||
init,
|
||||
|
|
@ -1408,5 +1761,14 @@ const Viz3D = (function () {
|
|||
initBlobInteraction: initBlobInteraction,
|
||||
submitBlobFeedback: submitBlobFeedback,
|
||||
showBlobFeedbackForm: showBlobFeedbackForm,
|
||||
// Identity API
|
||||
updateIdentities: updateIdentities,
|
||||
getBlobIdentity: getBlobIdentity,
|
||||
clearIdentities: clearIdentities,
|
||||
// Anomaly zone API
|
||||
setAnomalyZones: setAnomalyZones,
|
||||
focusOnZone: focusOnZone,
|
||||
focusOnPosition: focusOnPosition,
|
||||
clearAnomalyZones: clearAnomalyZones,
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
|
|
@ -13,6 +15,7 @@ import (
|
|||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spaxel/mothership/internal/events"
|
||||
"github.com/spaxel/mothership/internal/learning"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
|
@ -88,11 +91,63 @@ func DefaultAnomalyScoreConfig() AnomalyScoreConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// SecurityMode represents the current security mode state.
|
||||
type SecurityMode string
|
||||
|
||||
const (
|
||||
SecurityModeDisarmed SecurityMode = "disarmed"
|
||||
SecurityModeArmed SecurityMode = "armed"
|
||||
SecurityModeArmedStay SecurityMode = "armed_stay" // Armed but people are home
|
||||
)
|
||||
|
||||
// AutoAwayState tracks state for auto-away functionality.
|
||||
type AutoAwayState struct {
|
||||
LastMotionTime time.Time `json:"last_motion_time"`
|
||||
LastPersonCount int `json:"last_person_count"`
|
||||
AutoAwayTriggered bool `json:"auto_away_triggered"`
|
||||
}
|
||||
|
||||
// AutoDisarmState tracks state for auto-disarm functionality.
|
||||
type AutoDisarmState struct {
|
||||
RegisteredDeviceSeen bool `json:"registered_device_seen"`
|
||||
SeenDeviceMAC string `json:"seen_device_mac"`
|
||||
SeenDeviceRSSI int `json:"seen_device_rssi"`
|
||||
LastSeenTime time.Time `json:"last_seen_time"`
|
||||
}
|
||||
|
||||
// AnomalyCooldownConfig holds configuration for anomaly deduplication.
|
||||
type AnomalyCooldownConfig struct {
|
||||
// Cooldown duration per anomaly type+zone combination
|
||||
UnusualHourCooldown time.Duration `json:"unusual_hour_cooldown"` // Default: 30 minutes
|
||||
UnknownBLECooldown time.Duration `json:"unknown_ble_cooldown"` // Default: 10 minutes
|
||||
MotionDuringAwayCooldown time.Duration `json:"motion_during_away_cooldown"` // Default: 5 minutes
|
||||
UnusualDwellCooldown time.Duration `json:"unusual_dwell_cooldown"` // Default: 1 hour
|
||||
}
|
||||
|
||||
// DefaultAnomalyCooldownConfig returns default cooldown configuration.
|
||||
func DefaultAnomalyCooldownConfig() AnomalyCooldownConfig {
|
||||
return AnomalyCooldownConfig{
|
||||
UnusualHourCooldown: 30 * time.Minute,
|
||||
UnknownBLECooldown: 10 * time.Minute,
|
||||
MotionDuringAwayCooldown: 5 * time.Minute,
|
||||
UnusualDwellCooldown: 1 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// cooldownKey tracks the last time an anomaly was raised for deduplication.
|
||||
type cooldownKey struct {
|
||||
anomalyType events.AnomalyType
|
||||
zoneID string
|
||||
personID string
|
||||
deviceMAC string
|
||||
}
|
||||
|
||||
// Detector detects anomalies based on learned normal behaviour.
|
||||
type Detector struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
config AnomalyScoreConfig
|
||||
cooldownConfig AnomalyCooldownConfig
|
||||
|
||||
// Normal behaviour model (loaded from DB)
|
||||
behaviourSlots map[string]*NormalBehaviourSlot // key: "hour-zone"
|
||||
|
|
@ -105,6 +160,9 @@ type Detector struct {
|
|||
// Pending alert timers
|
||||
pendingAlerts map[string]*alertTimerState
|
||||
|
||||
// Anomaly cooldown tracking for deduplication
|
||||
anomalyCooldowns map[cooldownKey]time.Time // key -> last triggered time
|
||||
|
||||
// Model state
|
||||
learningStartTime time.Time
|
||||
modelReady bool
|
||||
|
|
@ -115,6 +173,12 @@ type Detector struct {
|
|||
registeredPeople map[string]string // person_id -> name
|
||||
deviceFirstSeen map[string]time.Time // MAC -> first seen time
|
||||
|
||||
// Security mode state
|
||||
securityMode SecurityMode
|
||||
autoAwayState AutoAwayState
|
||||
autoDisarmState AutoDisarmState
|
||||
manualOverrideUntil time.Time // Manual mode override expiry
|
||||
|
||||
// Providers
|
||||
zoneProvider ZoneProvider
|
||||
personProvider PersonProvider
|
||||
|
|
@ -122,9 +186,13 @@ type Detector struct {
|
|||
positionProvider PositionProvider
|
||||
alertHandler AlertHandler
|
||||
|
||||
// Feedback store for accuracy tracking
|
||||
feedbackStore *learning.FeedbackStore
|
||||
|
||||
// Callbacks
|
||||
onAnomaly func(event events.AnomalyEvent)
|
||||
onModeChange func(event events.SystemModeChangeEvent)
|
||||
onSecurityModeChange func(oldMode, newMode SecurityMode, reason string)
|
||||
}
|
||||
|
||||
// ZoneProvider provides zone information.
|
||||
|
|
@ -181,13 +249,16 @@ func NewDetector(dbPath string, config AnomalyScoreConfig) (*Detector, error) {
|
|||
d := &Detector{
|
||||
db: db,
|
||||
config: config,
|
||||
cooldownConfig: DefaultAnomalyCooldownConfig(),
|
||||
behaviourSlots: make(map[string]*NormalBehaviourSlot),
|
||||
dwellSlots: make(map[string]*DwellBehaviourSlot),
|
||||
activeAnomalies: make(map[string]*events.AnomalyEvent),
|
||||
pendingAlerts: make(map[string]*alertTimerState),
|
||||
anomalyCooldowns: make(map[cooldownKey]time.Time),
|
||||
registeredDevices: make(map[string]bool),
|
||||
registeredPeople: make(map[string]string),
|
||||
deviceFirstSeen: make(map[string]time.Time),
|
||||
securityMode: SecurityModeDisarmed,
|
||||
}
|
||||
|
||||
if err := d.migrate(); err != nil {
|
||||
|
|
@ -430,6 +501,13 @@ func (d *Detector) SetAlertHandler(h AlertHandler) {
|
|||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetFeedbackStore sets the feedback store for accuracy tracking.
|
||||
func (d *Detector) SetFeedbackStore(store *learning.FeedbackStore) {
|
||||
d.mu.Lock()
|
||||
d.feedbackStore = store
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetOnAnomaly sets callback for anomaly events.
|
||||
func (d *Detector) SetOnAnomaly(cb func(event events.AnomalyEvent)) {
|
||||
d.mu.Lock()
|
||||
|
|
@ -444,6 +522,78 @@ func (d *Detector) SetOnModeChange(cb func(event events.SystemModeChangeEvent))
|
|||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetOnSecurityModeChange sets callback for security mode changes.
|
||||
func (d *Detector) SetOnSecurityModeChange(cb func(oldMode, newMode SecurityMode, reason string)) {
|
||||
d.mu.Lock()
|
||||
d.onSecurityModeChange = cb
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetSecurityMode returns the current security mode.
|
||||
func (d *Detector) GetSecurityMode() SecurityMode {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.securityMode
|
||||
}
|
||||
|
||||
// SetSecurityMode sets the security mode.
|
||||
func (d *Detector) SetSecurityMode(mode SecurityMode, reason string) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.securityMode == mode {
|
||||
return // No change
|
||||
}
|
||||
|
||||
oldMode := d.securityMode
|
||||
d.securityMode = mode
|
||||
|
||||
log.Printf("[INFO] Security mode changed: %s -> %s (reason: %s)", oldMode, mode, reason)
|
||||
|
||||
// Fire callback
|
||||
if d.onSecurityModeChange != nil {
|
||||
go d.onSecurityModeChange(oldMode, mode, reason)
|
||||
}
|
||||
|
||||
// Persist to database
|
||||
d.db.Exec(`INSERT OR REPLACE INTO learning_state (key, value) VALUES ('security_mode', ?)`, string(mode))
|
||||
|
||||
// Clear manual override if switching to armed mode
|
||||
if mode == SecurityModeArmed || mode == SecurityModeArmedStay {
|
||||
d.manualOverrideUntil = time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
// IsSecurityModeActive returns true if security mode is armed or armed_stay.
|
||||
func (d *Detector) IsSecurityModeActive() bool {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.securityMode == SecurityModeArmed || d.securityMode == SecurityModeArmedStay
|
||||
}
|
||||
|
||||
// SetManualOverride sets a temporary override for security mode.
|
||||
func (d *Detector) SetManualOverride(duration time.Duration) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.manualOverrideUntil = time.Now().Add(duration)
|
||||
log.Printf("[INFO] Manual security override set for %v", duration)
|
||||
}
|
||||
|
||||
// ClearManualOverride clears the manual override.
|
||||
func (d *Detector) ClearManualOverride() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.manualOverrideUntil = time.Time{}
|
||||
log.Printf("[INFO] Manual security override cleared")
|
||||
}
|
||||
|
||||
// IsManualOverrideActive returns true if manual override is active.
|
||||
func (d *Detector) IsManualOverrideActive() bool {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return !d.manualOverrideUntil.IsZero() && time.Now().Before(d.manualOverrideUntil)
|
||||
}
|
||||
|
||||
// SetRegisteredDevices sets the list of registered BLE devices.
|
||||
func (d *Detector) SetRegisteredDevices(devices []string) {
|
||||
d.mu.Lock()
|
||||
|
|
@ -506,6 +656,15 @@ func (d *Detector) ProcessOccupancy(zoneID string, personCount int, bleDevices [
|
|||
|
||||
// Check if this is an unusual hour (low expected occupancy but we see people)
|
||||
if personCount > 0 && slot.ExpectedOccupancy < 0.1 {
|
||||
// Check cooldown for this anomaly type+zone
|
||||
cooldownKey := cooldownKey{
|
||||
anomalyType: events.AnomalyUnusualHour,
|
||||
zoneID: zoneID,
|
||||
}
|
||||
if d.isInCooldown(cooldownKey, d.cooldownConfig.UnusualHourCooldown) {
|
||||
return nil
|
||||
}
|
||||
|
||||
score := d.config.UnusualHourScore
|
||||
if isSecurityMode {
|
||||
score = d.config.UnusualHourScoreSecurity
|
||||
|
|
@ -539,6 +698,9 @@ func (d *Detector) ProcessOccupancy(zoneID string, personCount int, bleDevices [
|
|||
ExpectedOccupancy: slot.ExpectedOccupancy,
|
||||
}
|
||||
|
||||
// Mark cooldown
|
||||
d.anomalyCooldowns[cooldownKey] = now
|
||||
|
||||
return d.createAnomaly(&event, isSecurityMode)
|
||||
}
|
||||
|
||||
|
|
@ -569,6 +731,15 @@ func (d *Detector) ProcessBLEDevice(mac string, rssi int, isSecurityMode bool) *
|
|||
return nil // Not close enough to be concerning
|
||||
}
|
||||
|
||||
// Check cooldown for this device
|
||||
cooldownKey := cooldownKey{
|
||||
anomalyType: events.AnomalyUnknownBLE,
|
||||
deviceMAC: mac,
|
||||
}
|
||||
if d.isInCooldown(cooldownKey, d.cooldownConfig.UnknownBLECooldown) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if device was seen before
|
||||
seenBefore := false
|
||||
if d.deviceProvider != nil {
|
||||
|
|
@ -610,6 +781,9 @@ func (d *Detector) ProcessBLEDevice(mac string, rssi int, isSecurityMode bool) *
|
|||
SeenBefore: seenBefore,
|
||||
}
|
||||
|
||||
// Mark cooldown
|
||||
d.anomalyCooldowns[cooldownKey] = now
|
||||
|
||||
return d.createAnomaly(&event, isSecurityMode)
|
||||
}
|
||||
|
||||
|
|
@ -620,6 +794,15 @@ func (d *Detector) ProcessMotionDuringAway(zoneID string, blobID int, isSecurity
|
|||
|
||||
now := time.Now()
|
||||
|
||||
// Check cooldown for this anomaly type+zone
|
||||
cooldownKey := cooldownKey{
|
||||
anomalyType: events.AnomalyMotionDuringAway,
|
||||
zoneID: zoneID,
|
||||
}
|
||||
if d.isInCooldown(cooldownKey, d.cooldownConfig.MotionDuringAwayCooldown) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// This anomaly always fires regardless of model training status
|
||||
score := d.config.MotionDuringAwayScore
|
||||
|
||||
|
|
@ -650,6 +833,9 @@ func (d *Detector) ProcessMotionDuringAway(zoneID string, blobID int, isSecurity
|
|||
Position: pos,
|
||||
}
|
||||
|
||||
// Mark cooldown
|
||||
d.anomalyCooldowns[cooldownKey] = now
|
||||
|
||||
return d.createAnomaly(&event, isSecurityMode)
|
||||
}
|
||||
|
||||
|
|
@ -683,6 +869,16 @@ func (d *Detector) ProcessDwellDuration(zoneID, personID string, dwellDuration t
|
|||
|
||||
// Check if dwelling for > 5x mean
|
||||
if dwellDuration > time.Duration(float64(slot.MeanDwellDuration)*d.config.DwellMultiplierThreshold) {
|
||||
// Check cooldown for this anomaly type+zone+person
|
||||
cooldownKey := cooldownKey{
|
||||
anomalyType: events.AnomalyUnusualDwell,
|
||||
zoneID: zoneID,
|
||||
personID: personID,
|
||||
}
|
||||
if d.isInCooldown(cooldownKey, d.cooldownConfig.UnusualDwellCooldown) {
|
||||
return nil
|
||||
}
|
||||
|
||||
score := d.config.UnusualDwellScore
|
||||
|
||||
// Get names
|
||||
|
|
@ -709,6 +905,9 @@ func (d *Detector) ProcessDwellDuration(zoneID, personID string, dwellDuration t
|
|||
ExpectedDwell: slot.MeanDwellDuration,
|
||||
}
|
||||
|
||||
// Mark cooldown
|
||||
d.anomalyCooldowns[cooldownKey] = now
|
||||
|
||||
return d.createAnomaly(&event, isSecurityMode)
|
||||
}
|
||||
|
||||
|
|
@ -747,6 +946,25 @@ func (d *Detector) getAlertThreshold(isSecurityMode bool) float64 {
|
|||
return d.config.AlertThresholdNormal
|
||||
}
|
||||
|
||||
// isInCooldown checks if an anomaly key is in cooldown period.
|
||||
func (d *Detector) isInCooldown(key cooldownKey, duration time.Duration) bool {
|
||||
if lastTime, exists := d.anomalyCooldowns[key]; exists {
|
||||
return time.Since(lastTime) < duration
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cleanupStaleCooldowns removes expired cooldown entries.
|
||||
func (d *Detector) cleanupStaleCooldowns() {
|
||||
now := time.Now()
|
||||
maxCooldown := d.cooldownConfig.UnusualDwellCooldown // Use the longest cooldown
|
||||
for key, lastTime := range d.anomalyCooldowns {
|
||||
if now.Sub(lastTime) > maxCooldown {
|
||||
delete(d.anomalyCooldowns, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Detector) recordOccupancySample(hourOfWeek int, zoneID string, personCount int, bleDevices []string, timestamp time.Time) {
|
||||
devicesJSON, _ := jsonMarshal(bleDevices)
|
||||
_, err := d.db.Exec(`
|
||||
|
|
@ -919,11 +1137,60 @@ func (d *Detector) AcknowledgeAnomaly(anomalyID, feedback, acknowledgedBy string
|
|||
return err
|
||||
}
|
||||
|
||||
// Record feedback to learning store for accuracy tracking
|
||||
if d.feedbackStore != nil && feedback != "" {
|
||||
feedbackType := mapFeedbackToLearningType(feedback)
|
||||
details := map[string]interface{}{
|
||||
"anomaly_type": string(event.Type),
|
||||
"score": event.Score,
|
||||
"zone_id": event.ZoneID,
|
||||
"person_id": event.PersonID,
|
||||
"device_mac": event.DeviceMAC,
|
||||
"hour_of_week": event.HourOfWeek,
|
||||
"acknowledged_by": acknowledgedBy,
|
||||
}
|
||||
if event.Position.X != 0 || event.Position.Y != 0 || event.Position.Z != 0 {
|
||||
details["position_x"] = event.Position.X
|
||||
details["position_y"] = event.Position.Y
|
||||
details["position_z"] = event.Position.Z
|
||||
}
|
||||
|
||||
learningFeedback := learning.FeedbackRecord{
|
||||
ID: uuid.New().String(),
|
||||
EventID: anomalyID,
|
||||
EventType: learning.Anomaly,
|
||||
FeedbackType: feedbackType,
|
||||
Details: details,
|
||||
Timestamp: time.Now(),
|
||||
Applied: false,
|
||||
}
|
||||
|
||||
if err := d.feedbackStore.RecordFeedback(learningFeedback); err != nil {
|
||||
log.Printf("[WARN] Failed to record anomaly feedback to learning store: %v", err)
|
||||
} else {
|
||||
log.Printf("[INFO] Recorded anomaly feedback: %s -> %s", anomalyID, feedbackType)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[INFO] Anomaly acknowledged: %s (feedback: %s)", anomalyID, feedback)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mapFeedbackToLearningType maps anomaly feedback to learning feedback types.
|
||||
func mapFeedbackToLearningType(feedback string) learning.FeedbackType {
|
||||
switch feedback {
|
||||
case "expected":
|
||||
return learning.FalsePositive // User says it was expected, so detection was wrong
|
||||
case "intrusion":
|
||||
return learning.TruePositive // User confirms it was real
|
||||
case "false_alarm":
|
||||
return learning.FalsePositive
|
||||
default:
|
||||
return learning.FalsePositive
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateBehaviourModel updates the behaviour model from collected samples.
|
||||
// Should be called periodically (e.g., weekly).
|
||||
func (d *Detector) UpdateBehaviourModel() error {
|
||||
|
|
@ -1010,7 +1277,7 @@ func (d *Detector) UpdateBehaviourModel() error {
|
|||
dwellRows, err := d.db.Query(`
|
||||
SELECT hour_of_week, zone_id, person_id,
|
||||
AVG(dwell_ns) as mean_dwell_ns,
|
||||
0 as std_dwell_ns,
|
||||
SQRT(MAX(0, AVG(dwell_ns * dwell_ns) - AVG(dwell_ns) * AVG(dwell_ns))) as std_dwell_ns,
|
||||
COUNT(*) as sample_count
|
||||
FROM dwell_samples
|
||||
GROUP BY hour_of_week, zone_id, person_id
|
||||
|
|
@ -1185,64 +1452,13 @@ func nullTime(t time.Time) interface{} {
|
|||
return t.UnixNano()
|
||||
}
|
||||
|
||||
// JSON helpers (avoid import cycle)
|
||||
var jsonMarshal = func(v interface{}) ([]byte, error) {
|
||||
// Simple inline implementation to avoid import
|
||||
switch val := v.(type) {
|
||||
case []string:
|
||||
if len(val) == 0 {
|
||||
return []byte("[]"), nil
|
||||
}
|
||||
result := "["
|
||||
for i, s := range val {
|
||||
if i > 0 {
|
||||
result += ","
|
||||
}
|
||||
result += `"` + s + `"`
|
||||
}
|
||||
result += "]"
|
||||
return []byte(result), nil
|
||||
case map[string]float64:
|
||||
if len(val) == 0 {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
result := "{"
|
||||
first := true
|
||||
for k, v := range val {
|
||||
if !first {
|
||||
result += ","
|
||||
}
|
||||
result += fmt.Sprintf(`"%s":%f`, k, v)
|
||||
first = false
|
||||
}
|
||||
result += "}"
|
||||
return []byte(result), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported type")
|
||||
}
|
||||
// JSON helpers using standard library
|
||||
func jsonMarshal(v interface{}) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
var jsonUnmarshal = func(data string, v interface{}) error {
|
||||
// Simple inline implementation
|
||||
switch ptr := v.(type) {
|
||||
case *[]string:
|
||||
if data == "[]" || data == "" {
|
||||
*ptr = nil
|
||||
return nil
|
||||
}
|
||||
// Very simple parsing for string arrays
|
||||
*ptr = []string{} // Simplified - would need proper JSON parsing
|
||||
return nil
|
||||
case *map[string]float64:
|
||||
if data == "{}" || data == "" {
|
||||
*ptr = make(map[string]float64)
|
||||
return nil
|
||||
}
|
||||
*ptr = make(map[string]float64) // Simplified
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported type")
|
||||
}
|
||||
func jsonUnmarshal(data string, v interface{}) error {
|
||||
return json.Unmarshal([]byte(data), v)
|
||||
}
|
||||
|
||||
// Math helper
|
||||
|
|
|
|||
|
|
@ -111,3 +111,142 @@ func writeJSON(w http.ResponseWriter, v interface{}) {
|
|||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// AnomalyHandler provides REST API handlers for anomaly detection.
|
||||
type AnomalyHandler struct {
|
||||
detector *Detector
|
||||
}
|
||||
|
||||
// NewAnomalyHandler creates a new anomaly handler.
|
||||
func NewAnomalyHandler(detector *Detector) *AnomalyHandler {
|
||||
return &AnomalyHandler{detector: detector}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers anomaly API routes on the given router.
|
||||
func (h *AnomalyHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/api/anomalies", h.handleGetAnomalies)
|
||||
r.Get("/api/anomalies/active", h.handleGetActiveAnomalies)
|
||||
r.Get("/api/anomalies/history", h.handleGetHistory)
|
||||
r.Post("/api/anomalies/{id}/acknowledge", h.handleAcknowledge)
|
||||
r.Get("/api/anomalies/summary", h.handleGetSummary)
|
||||
r.Get("/api/anomalies/learning", h.handleGetLearningProgress)
|
||||
r.Post("/api/anomalies/model/update", h.handleUpdateModel)
|
||||
}
|
||||
|
||||
// handleGetAnomalies returns all anomalies (active + recent history).
|
||||
func (h *AnomalyHandler) handleGetAnomalies(w http.ResponseWriter, r *http.Request) {
|
||||
if h.detector == nil {
|
||||
http.Error(w, "anomaly detector not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
active := h.detector.GetActiveAnomalies()
|
||||
history := h.detector.GetAnomalyHistory(50)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"active": active,
|
||||
"history": history,
|
||||
}
|
||||
writeJSON(w, response)
|
||||
}
|
||||
|
||||
// handleGetActiveAnomalies returns only active (unacknowledged) anomalies.
|
||||
func (h *AnomalyHandler) handleGetActiveAnomalies(w http.ResponseWriter, r *http.Request) {
|
||||
if h.detector == nil {
|
||||
http.Error(w, "anomaly detector not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
active := h.detector.GetActiveAnomalies()
|
||||
writeJSON(w, active)
|
||||
}
|
||||
|
||||
// handleGetHistory returns anomaly history.
|
||||
func (h *AnomalyHandler) handleGetHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if h.detector == nil {
|
||||
http.Error(w, "anomaly detector not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 100
|
||||
if limitStr != "" {
|
||||
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
history := h.detector.GetAnomalyHistory(limit)
|
||||
writeJSON(w, history)
|
||||
}
|
||||
|
||||
// handleAcknowledge acknowledges an anomaly.
|
||||
func (h *AnomalyHandler) handleAcknowledge(w http.ResponseWriter, r *http.Request) {
|
||||
if h.detector == nil {
|
||||
http.Error(w, "anomaly detector not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
anomalyID := chi.URLParam(r, "id")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if err := h.detector.AcknowledgeAnomaly(anomalyID, req.Feedback, req.By); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]string{"status": "acknowledged"})
|
||||
}
|
||||
|
||||
// handleGetSummary returns the weekly anomaly summary.
|
||||
func (h *AnomalyHandler) handleGetSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if h.detector == nil {
|
||||
http.Error(w, "anomaly detector not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
summary := h.detector.GetWeeklySummary()
|
||||
writeJSON(w, summary)
|
||||
}
|
||||
|
||||
// handleGetLearningProgress returns the learning progress.
|
||||
func (h *AnomalyHandler) handleGetLearningProgress(w http.ResponseWriter, r *http.Request) {
|
||||
if h.detector == nil {
|
||||
http.Error(w, "anomaly detector not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
progress := h.detector.GetLearningProgress()
|
||||
ready := h.detector.IsModelReady()
|
||||
|
||||
response := map[string]interface{}{
|
||||
"progress": progress,
|
||||
"model_ready": ready,
|
||||
"days_learned": int(progress * 7),
|
||||
"days_remaining": int((1 - progress) * 7),
|
||||
}
|
||||
writeJSON(w, response)
|
||||
}
|
||||
|
||||
// handleUpdateModel triggers a behaviour model update.
|
||||
func (h *AnomalyHandler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
||||
if h.detector == nil {
|
||||
http.Error(w, "anomaly detector not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.detector.UpdateBehaviourModel(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]string{"status": "updated"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -347,10 +348,13 @@ func (de *DiagnosticEngine) checkWiFiCongestion(linkID string, history []LinkHea
|
|||
return nil
|
||||
}
|
||||
|
||||
// Find the earliest timestamp in the history
|
||||
// Find the time span of history (handles both ascending and descending order)
|
||||
startTime := history[0].Timestamp
|
||||
endTime := history[len(history)-1].Timestamp
|
||||
duration := endTime.Sub(startTime)
|
||||
if duration < 0 {
|
||||
duration = -duration
|
||||
}
|
||||
|
||||
if duration < minDuration {
|
||||
return nil
|
||||
|
|
@ -418,10 +422,13 @@ func (de *DiagnosticEngine) checkMetalInterference(linkID string, history []Link
|
|||
return nil
|
||||
}
|
||||
|
||||
// Check if we've had enough time
|
||||
// Check if we've had enough time (handles both ascending and descending order)
|
||||
startTime := history[0].Timestamp
|
||||
endTime := history[len(history)-1].Timestamp
|
||||
duration := endTime.Sub(startTime)
|
||||
if duration < 0 {
|
||||
duration = -duration
|
||||
}
|
||||
|
||||
if duration < minDuration {
|
||||
return nil
|
||||
|
|
@ -611,8 +618,14 @@ func (de *DiagnosticEngine) checkPeriodicInterference(linkID string, history []L
|
|||
return nil
|
||||
}
|
||||
|
||||
// Calculate events per hour
|
||||
historyDuration := history[len(history)-1].Timestamp.Sub(history[0].Timestamp)
|
||||
// Calculate events per hour (handles both ascending and descending order)
|
||||
startTime := history[0].Timestamp
|
||||
endTime := history[len(history)-1].Timestamp
|
||||
historyDuration := endTime.Sub(startTime)
|
||||
if historyDuration < 0 {
|
||||
historyDuration = -historyDuration
|
||||
}
|
||||
|
||||
if historyDuration < time.Hour {
|
||||
return nil // Need at least an hour of data
|
||||
}
|
||||
|
|
@ -747,14 +760,45 @@ func findVarianceSpikes(history []LinkHealthSnapshot, threshold float64) []time.
|
|||
baseline = 1 // Avoid division by zero
|
||||
}
|
||||
|
||||
var spikes []time.Time
|
||||
// First, identify all high-variance samples with their timestamps
|
||||
type spikeSample struct {
|
||||
ts time.Time
|
||||
variance float64
|
||||
}
|
||||
var allSpikes []spikeSample
|
||||
for _, h := range history {
|
||||
if h.DeltaRMSVariance > baseline*threshold {
|
||||
spikes = append(spikes, h.Timestamp)
|
||||
allSpikes = append(allSpikes, spikeSample{ts: h.Timestamp, variance: h.DeltaRMSVariance})
|
||||
}
|
||||
}
|
||||
|
||||
return spikes
|
||||
if len(allSpikes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort spikes by timestamp
|
||||
sort.Slice(allSpikes, func(i, j int) bool {
|
||||
return allSpikes[i].ts.Before(allSpikes[j].ts)
|
||||
})
|
||||
|
||||
// Cluster consecutive spikes (within 2 minutes) into events
|
||||
// Return the start time of each event
|
||||
var events []time.Time
|
||||
eventStart := allSpikes[0].ts
|
||||
lastSpikeTime := allSpikes[0].ts
|
||||
|
||||
for i := 1; i < len(allSpikes); i++ {
|
||||
// If this spike is more than 2 minutes after the last, it's a new event
|
||||
if allSpikes[i].ts.Sub(lastSpikeTime) > 2*time.Minute {
|
||||
events = append(events, eventStart)
|
||||
eventStart = allSpikes[i].ts
|
||||
}
|
||||
lastSpikeTime = allSpikes[i].ts
|
||||
}
|
||||
// Add the last event
|
||||
events = append(events, eventStart)
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
func isPeriodic(spikes []time.Time, minInterval, maxInterval time.Duration) bool {
|
||||
|
|
@ -762,10 +806,17 @@ func isPeriodic(spikes []time.Time, minInterval, maxInterval time.Duration) bool
|
|||
return false
|
||||
}
|
||||
|
||||
// Sort spikes by timestamp to handle any order
|
||||
sortedSpikes := make([]time.Time, len(spikes))
|
||||
copy(sortedSpikes, spikes)
|
||||
sort.Slice(sortedSpikes, func(i, j int) bool {
|
||||
return sortedSpikes[i].Before(sortedSpikes[j])
|
||||
})
|
||||
|
||||
// Calculate intervals between spikes
|
||||
intervals := make([]time.Duration, len(spikes)-1)
|
||||
for i := 0; i < len(spikes)-1; i++ {
|
||||
intervals[i] = spikes[i+1].Sub(spikes[i])
|
||||
intervals := make([]time.Duration, len(sortedSpikes)-1)
|
||||
for i := 0; i < len(sortedSpikes)-1; i++ {
|
||||
intervals[i] = sortedSpikes[i+1].Sub(sortedSpikes[i])
|
||||
}
|
||||
|
||||
// Check if intervals are relatively consistent (within 50% of each other)
|
||||
|
|
|
|||
|
|
@ -106,10 +106,11 @@ func TestRule2_WiFiCongestion(t *testing.T) {
|
|||
})
|
||||
|
||||
// Create 15 minutes of history with low packet rate (14 Hz = 70% health)
|
||||
// Samples must be in chronological order (oldest first) for duration calculation
|
||||
samples := make([]LinkHealthSnapshot, 16)
|
||||
for i := 0; i < 16; i++ {
|
||||
samples[i] = LinkHealthSnapshot{
|
||||
Timestamp: now.Add(-time.Duration(i) * time.Minute),
|
||||
Timestamp: now.Add(-15 * time.Minute).Add(time.Duration(i) * time.Minute),
|
||||
SNR: 0.7,
|
||||
PhaseStability: 0.3,
|
||||
PacketRate: 14.0, // 14 Hz out of 20 Hz = 70% health (< 80%)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/spaxel/mothership/internal/events"
|
||||
)
|
||||
|
||||
// Handler serves the fleet REST API.
|
||||
|
|
@ -36,6 +37,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||
r.Delete("/api/nodes/{mac}", h.deleteNode)
|
||||
r.Post("/api/nodes/virtual", h.addVirtualNode)
|
||||
r.Put("/api/room", h.updateRoom)
|
||||
// System mode endpoints
|
||||
r.Get("/api/mode", h.getSystemMode)
|
||||
r.Post("/api/mode", h.setSystemMode)
|
||||
}
|
||||
|
||||
func (h *Handler) listNodes(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -197,7 +201,73 @@ func (h *Handler) updateRoom(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||
// ── System Mode endpoints ───────────────────────────────────────────────────────
|
||||
|
||||
type systemModeResponse struct {
|
||||
Mode string `json:"mode"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AutoAwayConfig autoAwayConfigResponse `json:"auto_away_config"`
|
||||
}
|
||||
|
||||
type autoAwayConfigResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AbsenceDurationSec int `json:"absence_duration_sec"`
|
||||
}
|
||||
|
||||
// getSystemMode returns the current system mode.
|
||||
func (h *Handler) getSystemMode(w http.ResponseWriter, r *http.Request) {
|
||||
mode := h.mgr.GetSystemMode()
|
||||
cfg := h.mgr.GetAutoAwayConfig()
|
||||
|
||||
resp := systemModeResponse{
|
||||
Mode: string(mode),
|
||||
AutoAwayConfig: autoAwayConfigResponse{
|
||||
Enabled: cfg.Enabled,
|
||||
AbsenceDurationSec: int(cfg.AbsenceDuration.Seconds()),
|
||||
},
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
type setSystemModeRequest struct {
|
||||
Mode string `json:"mode"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// setSystemMode sets the system mode manually.
|
||||
func (h *Handler) setSystemMode(w http.ResponseWriter, r *http.Request) {
|
||||
var req setSystemModeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var mode events.SystemMode
|
||||
switch req.Mode {
|
||||
case "home":
|
||||
mode = events.ModeHome
|
||||
case "away":
|
||||
mode = events.ModeAway
|
||||
case "sleep":
|
||||
mode = events.ModeSleep
|
||||
default:
|
||||
http.Error(w, "invalid mode: must be home, away, or sleep", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reason := req.Reason
|
||||
if reason == "" {
|
||||
reason = "manual"
|
||||
}
|
||||
|
||||
if err := h.mgr.SetSystemMode(mode, reason); err != nil {
|
||||
http.Error(w, "failed to set mode", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := systemModeResponse{
|
||||
Mode: string(mode),
|
||||
Reason: reason,
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import (
|
|||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spaxel/mothership/internal/events"
|
||||
)
|
||||
|
||||
// NodeStateNotifier is called when the manager sends a role or config to a node.
|
||||
|
|
@ -23,6 +25,50 @@ type RegistryBroadcaster interface {
|
|||
BroadcastRegistryState(nodes []NodeRecord, room RoomConfig)
|
||||
}
|
||||
|
||||
// ModeChangeBroadcaster is called when system mode changes.
|
||||
type ModeChangeBroadcaster interface {
|
||||
BroadcastSystemModeChange(event events.SystemModeChangeEvent)
|
||||
}
|
||||
|
||||
// BLEPresenceProvider provides BLE device presence information for auto-away detection.
|
||||
type BLEPresenceProvider interface {
|
||||
// GetAllRegisteredDevices returns all registered BLE devices (MAC -> person_id)
|
||||
GetAllRegisteredDevices() (map[string]string, error)
|
||||
// GetRecentRSSIObservations returns recent RSSI observations for a device
|
||||
GetRecentRSSIObservations(mac string, maxAge time.Duration) []BLEObservation
|
||||
}
|
||||
|
||||
// PersonNameProvider provides person name lookups for mode change events.
|
||||
type PersonNameProvider interface {
|
||||
GetPersonName(personID string) string
|
||||
}
|
||||
|
||||
// BLEObservation represents a BLE RSSI observation with device info.
|
||||
type BLEObservation struct {
|
||||
DeviceMAC string // The BLE device MAC address
|
||||
NodeMAC string // The node that observed this device
|
||||
RSSIdBm int
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// AutoAwayConfig holds configuration for auto-away detection.
|
||||
type AutoAwayConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AbsenceDuration time.Duration `json:"absence_duration"` // Default: 15 minutes
|
||||
AutoDisarmRSSI int `json:"auto_disarm_rssi"` // Default: -70 dBm
|
||||
ManualOverridePause time.Duration `json:"manual_override_pause"` // Default: 30 minutes
|
||||
}
|
||||
|
||||
// DefaultAutoAwayConfig returns default auto-away configuration.
|
||||
func DefaultAutoAwayConfig() AutoAwayConfig {
|
||||
return AutoAwayConfig{
|
||||
Enabled: true,
|
||||
AbsenceDuration: 15 * time.Minute,
|
||||
AutoDisarmRSSI: -70,
|
||||
ManualOverridePause: 30 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// Manager handles fleet-level operations: role assignment, stagger scheduling, and self-healing.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
|
|
@ -41,14 +87,31 @@ type Manager struct {
|
|||
|
||||
// healTick is how often we check for stale/missing assignments.
|
||||
healTick time.Duration
|
||||
|
||||
// System mode management
|
||||
systemMode events.SystemMode
|
||||
modeChangeBroadcaster ModeChangeBroadcaster
|
||||
autoAwayConfig AutoAwayConfig
|
||||
blePresenceProvider BLEPresenceProvider
|
||||
personProvider PersonNameProvider
|
||||
manualOverrideUntil time.Time
|
||||
lastDeviceSeen map[string]time.Time // MAC -> last seen time
|
||||
modeCheckInterval time.Duration
|
||||
|
||||
// Callback for mode changes
|
||||
onModeChange func(events.SystemModeChangeEvent)
|
||||
}
|
||||
|
||||
// NewManager creates a fleet manager backed by registry.
|
||||
func NewManager(reg *Registry) *Manager {
|
||||
return &Manager{
|
||||
registry: reg,
|
||||
online: make(map[string]struct{}),
|
||||
healTick: 60 * time.Second,
|
||||
registry: reg,
|
||||
online: make(map[string]struct{}),
|
||||
healTick: 60 * time.Second,
|
||||
systemMode: events.ModeHome,
|
||||
autoAwayConfig: DefaultAutoAwayConfig(),
|
||||
lastDeviceSeen: make(map[string]time.Time),
|
||||
modeCheckInterval: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -311,3 +374,253 @@ func (m *Manager) broadcastRegistry() {
|
|||
|
||||
bcaster.BroadcastRegistryState(nodes, *room)
|
||||
}
|
||||
|
||||
// ─── System Mode Management ─────────────────────────────────────────────────────
|
||||
|
||||
// SetModeChangeBroadcaster sets the broadcaster for mode change events.
|
||||
func (m *Manager) SetModeChangeBroadcaster(b ModeChangeBroadcaster) {
|
||||
m.mu.Lock()
|
||||
m.modeChangeBroadcaster = b
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetBLEPresenceProvider sets the BLE presence provider for auto-away detection.
|
||||
func (m *Manager) SetBLEPresenceProvider(p BLEPresenceProvider) {
|
||||
m.mu.Lock()
|
||||
m.blePresenceProvider = p
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// ProcessBLEObservations processes BLE observations for auto-away/disarm detection.
|
||||
// This should be called when BLE data is received from nodes.
|
||||
func (m *Manager) ProcessBLEObservations(observations []BLEObservation) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Skip if no BLE provider or auto-away is disabled
|
||||
if m.blePresenceProvider == nil || !m.autoAwayConfig.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if manual override is active
|
||||
if time.Now().Before(m.manualOverrideUntil) {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Get all registered devices
|
||||
registeredDevices, err := m.blePresenceProvider.GetAllRegisteredDevices()
|
||||
if err != nil {
|
||||
log.Printf("[WARN] fleet: get registered devices: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for auto-disarm: any registered device seen with RSSI > threshold
|
||||
if m.systemMode == events.ModeAway {
|
||||
for _, obs := range observations {
|
||||
if personID, isRegistered := registeredDevices[obs.DeviceMAC]; isRegistered {
|
||||
if obs.RSSIdBm >= m.autoAwayConfig.AutoDisarmRSSI {
|
||||
// Get person name if available
|
||||
personName := ""
|
||||
if m.personProvider != nil {
|
||||
personName = m.personProvider.GetPersonName(personID)
|
||||
}
|
||||
|
||||
// Auto-disarm
|
||||
prevMode := m.systemMode
|
||||
m.systemMode = events.ModeHome
|
||||
|
||||
event := events.SystemModeChangeEvent{
|
||||
PreviousMode: prevMode,
|
||||
NewMode: events.ModeHome,
|
||||
Reason: "auto_disarm",
|
||||
Timestamp: now,
|
||||
PersonID: personID,
|
||||
PersonName: personName,
|
||||
}
|
||||
|
||||
if m.modeChangeBroadcaster != nil {
|
||||
m.modeChangeBroadcaster.BroadcastSystemModeChange(event)
|
||||
}
|
||||
|
||||
if m.onModeChange != nil {
|
||||
go m.onModeChange(event)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] fleet: auto-disarm triggered - registered device %s seen (RSSI: %d)", obs.DeviceMAC, obs.RSSIdBm)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update last seen times for registered devices
|
||||
for _, obs := range observations {
|
||||
if _, isRegistered := registeredDevices[obs.DeviceMAC]; isRegistered {
|
||||
m.lastDeviceSeen[obs.DeviceMAC] = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CheckAutoAway checks if all registered devices have been absent for the configured duration.
|
||||
// This should be called periodically.
|
||||
func (m *Manager) CheckAutoAway() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Skip if no BLE provider or auto-away is disabled
|
||||
if m.blePresenceProvider == nil || !m.autoAwayConfig.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if manual override is active
|
||||
if time.Now().Before(m.manualOverrideUntil) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't auto-away if already away
|
||||
if m.systemMode == events.ModeAway {
|
||||
return
|
||||
}
|
||||
|
||||
// Get all registered devices
|
||||
registeredDevices, err := m.blePresenceProvider.GetAllRegisteredDevices()
|
||||
if err != nil {
|
||||
log.Printf("[WARN] fleet: get registered devices for auto-away: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(registeredDevices) == 0 {
|
||||
return // No registered devices, can't determine away status
|
||||
}
|
||||
|
||||
// Check if all devices have been absent for the configured duration
|
||||
now := time.Now()
|
||||
allAbsent := true
|
||||
|
||||
for mac := range registeredDevices {
|
||||
lastSeen, exists := m.lastDeviceSeen[mac]
|
||||
if !exists || now.Sub(lastSeen) >= m.autoAwayConfig.AbsenceDuration {
|
||||
// Device not seen recently
|
||||
continue
|
||||
}
|
||||
// At least one device is present
|
||||
allAbsent = false
|
||||
break
|
||||
}
|
||||
|
||||
if allAbsent {
|
||||
// Auto-away
|
||||
prevMode := m.systemMode
|
||||
m.systemMode = events.ModeAway
|
||||
|
||||
event := events.SystemModeChangeEvent{
|
||||
PreviousMode: prevMode,
|
||||
NewMode: events.ModeAway,
|
||||
Reason: "auto_away",
|
||||
Timestamp: now,
|
||||
}
|
||||
|
||||
if m.modeChangeBroadcaster != nil {
|
||||
m.modeChangeBroadcaster.BroadcastSystemModeChange(event)
|
||||
}
|
||||
|
||||
if m.onModeChange != nil {
|
||||
go m.onModeChange(event)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] fleet: auto-away activated - all BLE devices absent for %v", m.autoAwayConfig.AbsenceDuration)
|
||||
}
|
||||
}
|
||||
|
||||
// RunModeCheck starts the periodic auto-away check loop.
|
||||
func (m *Manager) RunModeCheck(ctx context.Context) {
|
||||
ticker := time.NewTicker(m.modeCheckInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.CheckAutoAway()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetAutoAwayConfig returns the current auto-away configuration.
|
||||
func (m *Manager) GetAutoAwayConfig() AutoAwayConfig {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.autoAwayConfig
|
||||
}
|
||||
|
||||
// SetAutoAwayConfig updates the auto-away configuration.
|
||||
func (m *Manager) SetAutoAwayConfig(cfg AutoAwayConfig) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.autoAwayConfig = cfg
|
||||
}
|
||||
|
||||
// SetPersonProvider sets the person name provider for mode change events.
|
||||
func (m *Manager) SetPersonProvider(p PersonNameProvider) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.personProvider = p
|
||||
}
|
||||
|
||||
// GetSystemMode returns the current system mode.
|
||||
func (m *Manager) GetSystemMode() events.SystemMode {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.systemMode
|
||||
}
|
||||
|
||||
// SetSystemMode manually sets the system mode with a reason.
|
||||
func (m *Manager) SetSystemMode(mode events.SystemMode, reason string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
prevMode := m.systemMode
|
||||
if prevMode == mode {
|
||||
return nil // No change needed
|
||||
}
|
||||
|
||||
m.systemMode = mode
|
||||
|
||||
// Set manual override pause
|
||||
m.manualOverrideUntil = time.Now().Add(m.autoAwayConfig.ManualOverridePause)
|
||||
|
||||
event := events.SystemModeChangeEvent{
|
||||
PreviousMode: prevMode,
|
||||
NewMode: mode,
|
||||
Reason: reason,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
if m.modeChangeBroadcaster != nil {
|
||||
m.modeChangeBroadcaster.BroadcastSystemModeChange(event)
|
||||
}
|
||||
|
||||
if m.onModeChange != nil {
|
||||
go m.onModeChange(event)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] fleet: system mode changed: %s -> %s (reason: %s)", prevMode, mode, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetOnModeChange sets the callback for mode change events.
|
||||
func (m *Manager) SetOnModeChange(cb func(events.SystemModeChangeEvent)) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.onModeChange = cb
|
||||
}
|
||||
|
||||
// IsSecurityMode returns true if the system is in away mode (security mode).
|
||||
func (m *Manager) IsSecurityMode() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.systemMode == events.ModeAway
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ type NodeRecord struct {
|
|||
MAC string
|
||||
Name string
|
||||
Role string
|
||||
PreviousRole string // Role before disconnect, for reconnect grace period
|
||||
WentOfflineAt time.Time // When the node went offline
|
||||
PosX float64
|
||||
PosY float64
|
||||
PosZ float64
|
||||
|
|
@ -25,6 +27,7 @@ type NodeRecord struct {
|
|||
LastSeenAt time.Time
|
||||
FirmwareVersion string
|
||||
ChipModel string
|
||||
HealthScore float64 // Latest health score from ambient confidence
|
||||
}
|
||||
|
||||
// RoomConfig stores room geometry.
|
||||
|
|
@ -79,6 +82,8 @@ func (r *Registry) migrate() error {
|
|||
mac TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'rx',
|
||||
previous_role TEXT NOT NULL DEFAULT '',
|
||||
went_offline_at INTEGER NOT NULL DEFAULT 0,
|
||||
pos_x REAL NOT NULL DEFAULT 0,
|
||||
pos_y REAL NOT NULL DEFAULT 0,
|
||||
pos_z REAL NOT NULL DEFAULT 0,
|
||||
|
|
@ -86,7 +91,8 @@ func (r *Registry) migrate() error {
|
|||
first_seen_at INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen_at INTEGER NOT NULL DEFAULT 0,
|
||||
firmware_version TEXT NOT NULL DEFAULT '',
|
||||
chip_model TEXT NOT NULL DEFAULT ''
|
||||
chip_model TEXT NOT NULL DEFAULT '',
|
||||
health_score REAL NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
|
|
@ -99,10 +105,34 @@ func (r *Registry) migrate() error {
|
|||
origin_z REAL NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS optimisation_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
trigger_reason TEXT NOT NULL DEFAULT '',
|
||||
mean_gdop_before REAL NOT NULL DEFAULT 0,
|
||||
mean_gdop_after REAL NOT NULL DEFAULT 0,
|
||||
coverage_delta REAL NOT NULL DEFAULT 0,
|
||||
nodes_before TEXT NOT NULL DEFAULT '',
|
||||
nodes_after TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO rooms (id, name, width, depth, height, origin_x, origin_z)
|
||||
VALUES ('main', 'Main', 6.0, 5.0, 2.5, 0, 0);
|
||||
`)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Run migrations for new columns (ignore "duplicate column" errors)
|
||||
migrations := []string{
|
||||
"ALTER TABLE nodes ADD COLUMN previous_role TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE nodes ADD COLUMN went_offline_at INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE nodes ADD COLUMN health_score REAL NOT NULL DEFAULT 0",
|
||||
}
|
||||
for _, m := range migrations {
|
||||
_, _ = r.db.Exec(m) // Ignore errors (column may already exist)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
|
|
@ -142,6 +172,60 @@ func (r *Registry) SetNodeRole(mac, role string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// SetNodePreviousRole saves the current role as previous_role for reconnect grace period.
|
||||
func (r *Registry) SetNodePreviousRole(mac, role string) error {
|
||||
_, err := r.db.Exec(`UPDATE nodes SET previous_role=? WHERE mac=?`, role, mac)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetNodeOffline marks a node as offline with timestamp.
|
||||
func (r *Registry) SetNodeOffline(mac string) error {
|
||||
now := time.Now().UnixNano()
|
||||
_, err := r.db.Exec(`UPDATE nodes SET went_offline_at=? WHERE mac=?`, now, mac)
|
||||
return err
|
||||
}
|
||||
|
||||
// ClearNodeOffline clears the offline timestamp.
|
||||
func (r *Registry) ClearNodeOffline(mac string) error {
|
||||
_, err := r.db.Exec(`UPDATE nodes SET went_offline_at=0 WHERE mac=?`, mac)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetNodeHealthScore updates the health score for a node.
|
||||
func (r *Registry) SetNodeHealthScore(mac string, score float64) error {
|
||||
_, err := r.db.Exec(`UPDATE nodes SET health_score=? WHERE mac=?`, score, mac)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetNodePreviousRole returns the previous role for a node.
|
||||
func (r *Registry) GetNodePreviousRole(mac string) (string, error) {
|
||||
var role string
|
||||
err := r.db.QueryRow(`SELECT previous_role FROM nodes WHERE mac=?`, mac).Scan(&role)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return role, err
|
||||
}
|
||||
|
||||
// GetNodeWentOfflineAt returns when a node went offline.
|
||||
func (r *Registry) GetNodeWentOfflineAt(mac string) (time.Time, error) {
|
||||
var ns int64
|
||||
err := r.db.QueryRow(`SELECT went_offline_at FROM nodes WHERE mac=?`, mac).Scan(&ns)
|
||||
if err == sql.ErrNoRows {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
if ns == 0 {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
return time.Unix(0, ns), err
|
||||
}
|
||||
|
||||
// SetNodeName updates the name for a node.
|
||||
func (r *Registry) SetNodeName(mac, name string) error {
|
||||
_, err := r.db.Exec(`UPDATE nodes SET name=? WHERE mac=?`, name, mac)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddVirtualNode inserts or updates a virtual node for coverage planning.
|
||||
func (r *Registry) AddVirtualNode(mac, name string, x, y, z float64) error {
|
||||
now := time.Now().UnixNano()
|
||||
|
|
@ -167,15 +251,25 @@ func (r *Registry) DeleteNode(mac string) error {
|
|||
// GetNode returns a single node record.
|
||||
func (r *Registry) GetNode(mac string) (*NodeRecord, error) {
|
||||
row := r.db.QueryRow(`
|
||||
SELECT mac, name, role, pos_x, pos_y, pos_z, virtual, first_seen_at, last_seen_at, firmware_version, chip_model
|
||||
SELECT mac, name, role, previous_role, went_offline_at, pos_x, pos_y, pos_z, virtual, first_seen_at, last_seen_at, firmware_version, chip_model, health_score
|
||||
FROM nodes WHERE mac=?`, mac)
|
||||
return scanNode(row)
|
||||
}
|
||||
|
||||
// GetNodePosition returns the 3D position of a node for BLE triangulation.
|
||||
// Implements ble.NodePositionAccessor interface.
|
||||
func (r *Registry) GetNodePosition(mac string) (x, y, z float64, ok bool) {
|
||||
node, err := r.GetNode(mac)
|
||||
if err != nil || node == nil {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return node.PosX, node.PosY, node.PosZ, true
|
||||
}
|
||||
|
||||
// GetAllNodes returns all node records ordered by first_seen_at.
|
||||
func (r *Registry) GetAllNodes() ([]NodeRecord, error) {
|
||||
rows, err := r.db.Query(`
|
||||
SELECT mac, name, role, pos_x, pos_y, pos_z, virtual, first_seen_at, last_seen_at, firmware_version, chip_model
|
||||
SELECT mac, name, role, previous_role, went_offline_at, pos_x, pos_y, pos_z, virtual, first_seen_at, last_seen_at, firmware_version, chip_model, health_score
|
||||
FROM nodes ORDER BY first_seen_at ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -194,6 +288,56 @@ func (r *Registry) GetAllNodes() ([]NodeRecord, error) {
|
|||
return nodes, rows.Err()
|
||||
}
|
||||
|
||||
// OptimisationHistoryRecord stores historical optimisation events
|
||||
type OptimisationHistoryRecord struct {
|
||||
ID int64
|
||||
Timestamp time.Time
|
||||
TriggerReason string
|
||||
MeanGDOPBefore float64
|
||||
MeanGDOPAfter float64
|
||||
CoverageDelta float64
|
||||
NodesBeforeJSON string
|
||||
NodesAfterJSON string
|
||||
}
|
||||
|
||||
// AddOptimisationHistory adds an optimisation event to the history
|
||||
func (r *Registry) AddOptimisationHistory(rec OptimisationHistoryRecord) error {
|
||||
_, err := r.db.Exec(`
|
||||
INSERT INTO optimisation_history (timestamp, trigger_reason, mean_gdop_before, mean_gdop_after, coverage_delta, nodes_before, nodes_after)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, rec.Timestamp.UnixNano(), rec.TriggerReason, rec.MeanGDOPBefore, rec.MeanGDOPAfter, rec.CoverageDelta, rec.NodesBeforeJSON, rec.NodesAfterJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetOptimisationHistory returns recent optimisation history
|
||||
func (r *Registry) GetOptimisationHistory(limit int) ([]OptimisationHistoryRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
rows, err := r.db.Query(`
|
||||
SELECT id, timestamp, trigger_reason, mean_gdop_before, mean_gdop_after, coverage_delta, nodes_before, nodes_after
|
||||
FROM optimisation_history
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []OptimisationHistoryRecord
|
||||
for rows.Next() {
|
||||
var rec OptimisationHistoryRecord
|
||||
var ns int64
|
||||
if err := rows.Scan(&rec.ID, &ns, &rec.TriggerReason, &rec.MeanGDOPBefore, &rec.MeanGDOPAfter, &rec.CoverageDelta, &rec.NodesBeforeJSON, &rec.NodesAfterJSON); err != nil {
|
||||
continue
|
||||
}
|
||||
rec.Timestamp = time.Unix(0, ns)
|
||||
records = append(records, rec)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
// GetRoom returns the main room configuration.
|
||||
func (r *Registry) GetRoom() (*RoomConfig, error) {
|
||||
row := r.db.QueryRow(`SELECT id, name, width, depth, height, origin_x, origin_z FROM rooms WHERE id='main'`)
|
||||
|
|
@ -224,12 +368,12 @@ type scanner interface {
|
|||
func scanNode(s scanner) (*NodeRecord, error) {
|
||||
var n NodeRecord
|
||||
var virtual int
|
||||
var firstNS, lastNS int64
|
||||
var firstNS, lastNS, offlineNS int64
|
||||
err := s.Scan(
|
||||
&n.MAC, &n.Name, &n.Role,
|
||||
&n.MAC, &n.Name, &n.Role, &n.PreviousRole, &offlineNS,
|
||||
&n.PosX, &n.PosY, &n.PosZ,
|
||||
&virtual, &firstNS, &lastNS,
|
||||
&n.FirmwareVersion, &n.ChipModel,
|
||||
&n.FirmwareVersion, &n.ChipModel, &n.HealthScore,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -241,6 +385,9 @@ func scanNode(s scanner) (*NodeRecord, error) {
|
|||
if lastNS > 0 {
|
||||
n.LastSeenAt = time.Unix(0, lastNS)
|
||||
}
|
||||
if offlineNS > 0 {
|
||||
n.WentOfflineAt = time.Unix(0, offlineNS)
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,16 +7,18 @@ import (
|
|||
|
||||
// LinkMotion describes one link's current motion state for fusion.
|
||||
type LinkMotion struct {
|
||||
NodeMAC string
|
||||
PeerMAC string
|
||||
DeltaRMS float64
|
||||
Motion bool
|
||||
NodeMAC string
|
||||
PeerMAC string
|
||||
DeltaRMS float64
|
||||
Motion bool
|
||||
HealthScore float64 // Link health score from signal processing (0-1)
|
||||
}
|
||||
|
||||
// NodePosition holds a node's (x, z) position in room coordinates.
|
||||
// NodePosition holds a node's (x, y, z) position in room coordinates.
|
||||
type NodePosition struct {
|
||||
MAC string
|
||||
X float64 // metres
|
||||
X float64 // metres (width)
|
||||
Y float64 // metres (height)
|
||||
Z float64 // metres (depth)
|
||||
}
|
||||
|
||||
|
|
@ -39,6 +41,12 @@ type Engine struct {
|
|||
peakThresh float64
|
||||
lastResult *FusionResult
|
||||
subscribers []chan FusionResult
|
||||
|
||||
// Learned weights (can be set externally)
|
||||
learnedWeights *LearnedWeights
|
||||
|
||||
// Spatial weight learner for per-zone weights
|
||||
spatialWeightLearner *SpatialWeightLearner
|
||||
}
|
||||
|
||||
// NewEngine creates a fusion engine for the given room dimensions.
|
||||
|
|
@ -66,6 +74,34 @@ func (e *Engine) RemoveNode(mac string) {
|
|||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetLearnedWeights sets the learned weights for self-improving localization
|
||||
func (e *Engine) SetLearnedWeights(weights *LearnedWeights) {
|
||||
e.mu.Lock()
|
||||
e.learnedWeights = weights
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetLearnedWeights returns the current learned weights
|
||||
func (e *Engine) GetLearnedWeights() *LearnedWeights {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.learnedWeights
|
||||
}
|
||||
|
||||
// SetSpatialWeightLearner sets the spatial weight learner for per-zone weights
|
||||
func (e *Engine) SetSpatialWeightLearner(learner *SpatialWeightLearner) {
|
||||
e.mu.Lock()
|
||||
e.spatialWeightLearner = learner
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetSpatialWeightLearner returns the current spatial weight learner
|
||||
func (e *Engine) GetSpatialWeightLearner() *SpatialWeightLearner {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.spatialWeightLearner
|
||||
}
|
||||
|
||||
// ResizeRoom rebuilds the grid for updated room dimensions.
|
||||
func (e *Engine) ResizeRoom(width, depth, originX, originZ float64) {
|
||||
e.mu.Lock()
|
||||
|
|
@ -75,12 +111,14 @@ func (e *Engine) ResizeRoom(width, depth, originX, originZ float64) {
|
|||
|
||||
// Fuse performs a single fusion step with the provided motion states.
|
||||
// It returns a FusionResult containing the normalised grid snapshot and peak positions.
|
||||
// If learned weights are available, they are applied to improve accuracy.
|
||||
func (e *Engine) Fuse(links []LinkMotion) *FusionResult {
|
||||
e.mu.RLock()
|
||||
nodePos := make(map[string]NodePosition, len(e.nodePos))
|
||||
for k, v := range e.nodePos {
|
||||
nodePos[k] = v
|
||||
}
|
||||
learnedWeights := e.learnedWeights
|
||||
e.mu.RUnlock()
|
||||
|
||||
e.grid.Reset()
|
||||
|
|
@ -95,7 +133,24 @@ func (e *Engine) Fuse(links []LinkMotion) *FusionResult {
|
|||
if !okA || !okB {
|
||||
continue
|
||||
}
|
||||
e.grid.AddLinkInfluence(posA.X, posA.Z, posB.X, posB.Z, lm.DeltaRMS)
|
||||
|
||||
// Apply learned weight multiplier if available
|
||||
weight := lm.DeltaRMS
|
||||
sigmaMultiplier := 0.0
|
||||
|
||||
if learnedWeights != nil {
|
||||
linkID := lm.NodeMAC + "-" + lm.PeerMAC
|
||||
weightMultiplier := learnedWeights.GetLinkWeight(linkID)
|
||||
weight *= weightMultiplier
|
||||
sigmaMultiplier = learnedWeights.GetLinkSigma(linkID)
|
||||
}
|
||||
|
||||
// Use the sigma-aware version if we have learned sigma
|
||||
if sigmaMultiplier != 0 {
|
||||
e.grid.AddLinkInfluenceWithSigma(posA.X, posA.Z, posB.X, posB.Z, weight, sigmaMultiplier)
|
||||
} else {
|
||||
e.grid.AddLinkInfluence(posA.X, posA.Z, posB.X, posB.Z, weight)
|
||||
}
|
||||
activeLinks++
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,12 @@ func (g *Grid) Reset() {
|
|||
// zone width parameter). We scale by the link weight so strongly-active links
|
||||
// dominate weakly-active ones.
|
||||
func (g *Grid) AddLinkInfluence(ax, az, bx, bz, weight float64) {
|
||||
g.AddLinkInfluenceWithSigma(ax, az, bx, bz, weight, 0)
|
||||
}
|
||||
|
||||
// AddLinkInfluenceWithSigma paints the Fresnel-zone influence with a learned sigma multiplier.
|
||||
// sigmaMultiplier adjusts the base sigma: 1.0 = default, <1.0 = narrower zone, >1.0 = wider zone
|
||||
func (g *Grid) AddLinkInfluenceWithSigma(ax, az, bx, bz, weight, sigmaMultiplier float64) {
|
||||
if weight <= 0 {
|
||||
return
|
||||
}
|
||||
|
|
@ -74,7 +80,21 @@ func (g *Grid) AddLinkInfluence(ax, az, bx, bz, weight float64) {
|
|||
// σ is chosen so the first Fresnel zone (excess = λ/2 ≈ 0.062m at 2.4GHz)
|
||||
// maps to ~1σ, giving comfortable spatial spread. In practice a wider
|
||||
// sigma (0.5m) gives better localisation for indoor multipath.
|
||||
sigma := math.Max(ab*0.25, 0.5)
|
||||
baseSigma := math.Max(ab*0.25, 0.5)
|
||||
|
||||
// Apply learned sigma multiplier
|
||||
sigma := baseSigma
|
||||
if sigmaMultiplier > 0 {
|
||||
sigma = baseSigma * sigmaMultiplier
|
||||
// Clamp to reasonable range
|
||||
if sigma < 0.2 {
|
||||
sigma = 0.2
|
||||
}
|
||||
if sigma > 2.0 {
|
||||
sigma = 2.0
|
||||
}
|
||||
}
|
||||
|
||||
twoSigSq := 2 * sigma * sigma
|
||||
|
||||
g.mu.Lock()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
package sleep
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -13,6 +14,10 @@ const (
|
|||
DefaultSleepStartHour = 22 // 10 PM
|
||||
DefaultSleepEndHour = 7 // 7 AM
|
||||
|
||||
// Session confirmation thresholds
|
||||
SessionConfirmDuration = 15 * time.Minute // Must be stationary for 15 min to confirm sleep onset
|
||||
WakeConfirmDuration = 2 * time.Minute // Must be moving for 2 min to confirm wake
|
||||
|
||||
// Scoring weights
|
||||
BreathingWeight = 0.4
|
||||
MotionWeight = 0.3
|
||||
|
|
@ -23,9 +28,18 @@ const (
|
|||
BreathingRateHigh = 25.0 // BPM - above this is concerning
|
||||
BreathingRateOptimal = 14.0 // BPM - optimal breathing rate
|
||||
|
||||
// Breathing anomaly thresholds (per task spec: <8 or >25 bpm)
|
||||
BreathingAnomalyLow = 8.0 // BPM - apnea indicator
|
||||
BreathingAnomalyHigh = 25.0 // BPM - hyperventilation indicator
|
||||
BreathingAnomalyDurationThreshold = 3 * time.Minute
|
||||
|
||||
// Motion thresholds (deltaRMS)
|
||||
QuietMotionThreshold = 0.015 // Below this is considered quiet
|
||||
RestlessThreshold = 0.04 // Above this is restless
|
||||
WakeMotionThreshold = 0.03 // Above this indicates potential wake episode
|
||||
|
||||
// Wake episode thresholds
|
||||
WakeEpisodeMinDuration = 3 * time.Second // Minimum duration to count as wake episode
|
||||
|
||||
// Sample collection
|
||||
SampleInterval = 30 * time.Second
|
||||
|
|
@ -98,15 +112,23 @@ type SleepMetrics struct {
|
|||
// Timing
|
||||
SleepStartTime time.Time `json:"sleep_start_time"`
|
||||
SleepEndTime time.Time `json:"sleep_end_time,omitempty"`
|
||||
SleepOnsetTime time.Time `json:"sleep_onset_time,omitempty"` // When sleep was confirmed (15 min stationary)
|
||||
TotalDuration time.Duration `json:"total_duration"`
|
||||
TimeInBed time.Duration `json:"time_in_bed"`
|
||||
|
||||
// Sleep efficiency (per task spec: (time_in_bed - waso) / time_in_bed * 100)
|
||||
SleepEfficiency float64 `json:"sleep_efficiency"` // 0-100%
|
||||
SleepLatencyMinutes float64 `json:"sleep_latency_minutes"` // Time from entering bedroom to sleep onset
|
||||
WASOMinutes float64 `json:"waso_minutes"` // Wake After Sleep Onset
|
||||
WakeEpisodeCount int `json:"wake_episode_count"`
|
||||
|
||||
// Breathing metrics
|
||||
AvgBreathingRate float64 `json:"avg_breathing_rate"`
|
||||
MinBreathingRate float64 `json:"min_breathing_rate"`
|
||||
MaxBreathingRate float64 `json:"max_breathing_rate"`
|
||||
BreathingRateStdDev float64 `json:"breathing_rate_std_dev"`
|
||||
BreathingScore float64 `json:"breathing_score"` // 0-100
|
||||
BreathingAnomalyCount int `json:"breathing_anomaly_count"` // Anomalies < 8 or > 25 bpm
|
||||
|
||||
// Motion metrics
|
||||
MotionEvents int `json:"motion_events"`
|
||||
|
|
@ -124,6 +146,28 @@ type SleepMetrics struct {
|
|||
QualityRating string `json:"quality_rating"` // poor/fair/good/excellent
|
||||
}
|
||||
|
||||
// Breathing anomaly thresholds are defined above (lines 32-34)
|
||||
|
||||
// WakeEpisode represents a period of wakefulness during sleep
|
||||
type WakeEpisode struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
EpisodeStart time.Time `json:"episode_start"`
|
||||
EpisodeEnd time.Time `json:"episode_end,omitempty"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
}
|
||||
|
||||
// BreathingAnomaly represents a detected breathing anomaly
|
||||
type BreathingAnomaly struct {
|
||||
ID string `json:"id"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time,omitempty"`
|
||||
RateBPM float64 `json:"rate_bpm"`
|
||||
AnomalyType string `json:"anomaly_type"` // "low" or "high"
|
||||
Duration time.Duration `json:"duration"`
|
||||
}
|
||||
|
||||
// SleepSession represents a complete sleep session
|
||||
type SleepSession struct {
|
||||
mu sync.RWMutex
|
||||
|
|
@ -137,6 +181,11 @@ type SleepSession struct {
|
|||
sessionDate time.Time // Date of sleep session (midnight of the night)
|
||||
isActive bool
|
||||
|
||||
// Session timing
|
||||
sessionStart time.Time // When person entered bedroom/started tracking
|
||||
sleepOnset time.Time // When sleep was confirmed (15 min after stationary detection)
|
||||
wakeTime time.Time // When session ended
|
||||
|
||||
// Sample buffers
|
||||
breathingSamples []BreathingSample
|
||||
motionSamples []MotionSample
|
||||
|
|
@ -145,6 +194,21 @@ type SleepSession struct {
|
|||
sleepPeriods []SleepPeriod
|
||||
currentPeriod *SleepPeriod
|
||||
|
||||
// Wake episode tracking
|
||||
wakeEpisodes []WakeEpisode
|
||||
currentWakeEpisode *WakeEpisode
|
||||
wakeEpisodeStart time.Time // Track when current wake period started
|
||||
|
||||
// Breathing anomaly tracking
|
||||
breathingAnomalies []BreathingAnomaly
|
||||
currentAnomaly *BreathingAnomaly
|
||||
anomalyStartTime time.Time
|
||||
anomalyType string
|
||||
|
||||
// Zone and identity
|
||||
zoneID string
|
||||
personID string
|
||||
|
||||
// Aggregated metrics (computed on demand)
|
||||
metrics *SleepMetrics
|
||||
|
||||
|
|
@ -280,6 +344,8 @@ func NewSleepSession(linkID string, sleepStartHour, sleepEndHour int) *SleepSess
|
|||
breathingSamples: make([]BreathingSample, 0, 1440), // ~12 hours at 30s intervals
|
||||
motionSamples: make([]MotionSample, 0, 1440),
|
||||
sleepPeriods: make([]SleepPeriod, 0, 100),
|
||||
wakeEpisodes: make([]WakeEpisode, 0, 50),
|
||||
breathingAnomalies: make([]BreathingAnomaly, 0, 20),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -297,10 +363,68 @@ func (ss *SleepSession) processBreathing(sample BreathingSample) {
|
|||
if !ss.isActive {
|
||||
ss.isActive = true
|
||||
ss.sessionDate = ss.getSleepDate(sample.Timestamp)
|
||||
ss.sessionStart = sample.Timestamp
|
||||
ss.metrics = nil // Reset metrics for new session
|
||||
}
|
||||
|
||||
ss.breathingSamples = append(ss.breathingSamples, sample)
|
||||
|
||||
// Detect breathing anomalies (apnea/hyperventilation indicators)
|
||||
if sample.IsDetected && sample.RateBPM > 0 {
|
||||
ss.detectBreathingAnomaly(sample)
|
||||
}
|
||||
}
|
||||
|
||||
// detectBreathingAnomaly checks for breathing rates outside normal range
|
||||
func (ss *SleepSession) detectBreathingAnomaly(sample BreathingSample) {
|
||||
isAnomalous := false
|
||||
anomalyType := ""
|
||||
|
||||
if sample.RateBPM < BreathingAnomalyLow && sample.RateBPM > 0 {
|
||||
isAnomalous = true
|
||||
anomalyType = "low" // Potential apnea
|
||||
} else if sample.RateBPM > BreathingAnomalyHigh {
|
||||
isAnomalous = true
|
||||
anomalyType = "high" // Potential hyperventilation
|
||||
}
|
||||
|
||||
if isAnomalous {
|
||||
if ss.anomalyStartTime.IsZero() {
|
||||
// Start tracking potential anomaly
|
||||
ss.anomalyStartTime = sample.Timestamp
|
||||
ss.anomalyType = anomalyType
|
||||
} else if ss.anomalyType == anomalyType {
|
||||
// Continue tracking same type of anomaly
|
||||
duration := sample.Timestamp.Sub(ss.anomalyStartTime)
|
||||
if duration >= BreathingAnomalyDurationThreshold && ss.currentAnomaly == nil {
|
||||
// Anomaly persisted for 3+ minutes - record it
|
||||
ss.currentAnomaly = &BreathingAnomaly{
|
||||
ID: fmt.Sprintf("%s-%d", ss.linkID, ss.anomalyStartTime.Unix()),
|
||||
StartTime: ss.anomalyStartTime,
|
||||
RateBPM: sample.RateBPM,
|
||||
AnomalyType: anomalyType,
|
||||
}
|
||||
ss.breathingAnomalies = append(ss.breathingAnomalies, *ss.currentAnomaly)
|
||||
}
|
||||
} else {
|
||||
// Different anomaly type - reset tracking
|
||||
ss.anomalyStartTime = sample.Timestamp
|
||||
ss.anomalyType = anomalyType
|
||||
}
|
||||
} else {
|
||||
// Breathing returned to normal - close any ongoing anomaly
|
||||
if ss.currentAnomaly != nil {
|
||||
ss.currentAnomaly.EndTime = sample.Timestamp
|
||||
ss.currentAnomaly.Duration = sample.Timestamp.Sub(ss.currentAnomaly.StartTime)
|
||||
// Update the last anomaly in the slice
|
||||
if len(ss.breathingAnomalies) > 0 {
|
||||
ss.breathingAnomalies[len(ss.breathingAnomalies)-1] = *ss.currentAnomaly
|
||||
}
|
||||
ss.currentAnomaly = nil
|
||||
}
|
||||
ss.anomalyStartTime = time.Time{}
|
||||
ss.anomalyType = ""
|
||||
}
|
||||
}
|
||||
|
||||
// processMotion processes a motion sample
|
||||
|
|
@ -317,15 +441,56 @@ func (ss *SleepSession) processMotion(sample MotionSample) {
|
|||
if !ss.isActive {
|
||||
ss.isActive = true
|
||||
ss.sessionDate = ss.getSleepDate(sample.Timestamp)
|
||||
ss.sessionStart = sample.Timestamp
|
||||
ss.metrics = nil
|
||||
}
|
||||
|
||||
// Track motion state changes
|
||||
ss.updateSleepState(sample)
|
||||
|
||||
// Track wake episodes during confirmed sleep
|
||||
if ss.sleepOnsetConfirmed() {
|
||||
ss.trackWakeEpisode(sample)
|
||||
}
|
||||
|
||||
ss.motionSamples = append(ss.motionSamples, sample)
|
||||
}
|
||||
|
||||
// sleepOnsetConfirmed returns true if sleep onset has been confirmed (15 min of stationary)
|
||||
func (ss *SleepSession) sleepOnsetConfirmed() bool {
|
||||
return !ss.sleepOnset.IsZero()
|
||||
}
|
||||
|
||||
// trackWakeEpisode tracks wake episodes during sleep
|
||||
func (ss *SleepSession) trackWakeEpisode(sample MotionSample) {
|
||||
// Wake episode starts when motion > threshold for sustained period
|
||||
if sample.DeltaRMS > RestlessThreshold || sample.MotionDetected {
|
||||
if ss.wakeEpisodeStart.IsZero() {
|
||||
// Start tracking potential wake episode
|
||||
ss.wakeEpisodeStart = sample.Timestamp
|
||||
} else {
|
||||
// Check if this has been sustained long enough
|
||||
duration := sample.Timestamp.Sub(ss.wakeEpisodeStart)
|
||||
if duration >= WakeEpisodeMinDuration && ss.currentWakeEpisode == nil {
|
||||
// Create new wake episode
|
||||
ss.currentWakeEpisode = &WakeEpisode{
|
||||
ID: fmt.Sprintf("%s-wake-%d", ss.linkID, ss.wakeEpisodeStart.Unix()),
|
||||
EpisodeStart: ss.wakeEpisodeStart,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Motion returned to quiet - close any ongoing wake episode
|
||||
if ss.currentWakeEpisode != nil {
|
||||
ss.currentWakeEpisode.EpisodeEnd = sample.Timestamp
|
||||
ss.currentWakeEpisode.Duration = sample.Timestamp.Sub(ss.currentWakeEpisode.EpisodeStart)
|
||||
ss.wakeEpisodes = append(ss.wakeEpisodes, *ss.currentWakeEpisode)
|
||||
ss.currentWakeEpisode = nil
|
||||
}
|
||||
ss.wakeEpisodeStart = time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
// updateSleepState updates the sleep state based on motion
|
||||
func (ss *SleepSession) updateSleepState(sample MotionSample) {
|
||||
prevState := ss.currentState
|
||||
|
|
@ -458,10 +623,21 @@ func (ss *SleepSession) calculateTiming(m *SleepMetrics) {
|
|||
m.SleepStartTime = ss.motionSamples[0].Timestamp
|
||||
m.SleepEndTime = ss.motionSamples[len(ss.motionSamples)-1].Timestamp
|
||||
|
||||
// Set sleep onset if confirmed
|
||||
if !ss.sleepOnset.IsZero() {
|
||||
m.SleepOnsetTime = ss.sleepOnset
|
||||
}
|
||||
|
||||
// Calculate time in bed
|
||||
if !m.SleepEndTime.IsZero() {
|
||||
m.TimeInBed = m.SleepEndTime.Sub(m.SleepStartTime)
|
||||
}
|
||||
|
||||
// Calculate sleep latency (time from entering bed to sleep onset)
|
||||
if !ss.sleepOnset.IsZero() && !ss.sessionStart.IsZero() {
|
||||
m.SleepLatencyMinutes = ss.sleepOnset.Sub(ss.sessionStart).Minutes()
|
||||
}
|
||||
|
||||
// Count actual sleep time (excluding awake periods)
|
||||
for _, period := range ss.sleepPeriods {
|
||||
if period.State != SleepStateAwake {
|
||||
|
|
@ -473,6 +649,28 @@ func (ss *SleepSession) calculateTiming(m *SleepMetrics) {
|
|||
if ss.currentPeriod != nil && ss.currentPeriod.State != SleepStateAwake {
|
||||
m.TotalDuration += time.Since(ss.currentPeriod.StartTime)
|
||||
}
|
||||
|
||||
// Calculate WASO (Wake After Sleep Onset) from wake episodes
|
||||
m.WakeEpisodeCount = len(ss.wakeEpisodes)
|
||||
var wasoDuration time.Duration
|
||||
for _, episode := range ss.wakeEpisodes {
|
||||
// Only count episodes after sleep onset
|
||||
if episode.EpisodeStart.After(ss.sleepOnset) {
|
||||
wasoDuration += episode.Duration
|
||||
}
|
||||
}
|
||||
m.WASOMinutes = wasoDuration.Minutes()
|
||||
|
||||
// Calculate sleep efficiency: (time_in_bed - waso) / time_in_bed * 100
|
||||
// Per task spec: a value above 85% is considered good sleep efficiency
|
||||
if m.TimeInBed > 0 {
|
||||
effectiveSleep := m.TimeInBed - wasoDuration
|
||||
m.SleepEfficiency = (float64(effectiveSleep) / float64(m.TimeInBed)) * 100
|
||||
// Cap at 100%
|
||||
if m.SleepEfficiency > 100 {
|
||||
m.SleepEfficiency = 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// calculateBreathingMetrics computes breathing quality metrics
|
||||
|
|
@ -510,6 +708,9 @@ func (ss *SleepSession) calculateBreathingMetrics(m *SleepMetrics) {
|
|||
m.BreathingRateStdDev = math.Sqrt(math.Max(0, variance))
|
||||
}
|
||||
|
||||
// Count breathing anomalies (per task spec: < 8 or > 25 bpm for > 3 minutes)
|
||||
m.BreathingAnomalyCount = len(ss.breathingAnomalies)
|
||||
|
||||
// Calculate breathing score (0-100)
|
||||
m.BreathingScore = ss.calculateBreathingScore(m.AvgBreathingRate, m.BreathingRateStdDev, m.MinBreathingRate, m.MaxBreathingRate)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,35 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spaxel/mothership/internal/events"
|
||||
"github.com/spaxel/mothership/internal/signal"
|
||||
"github.com/spaxel/mothership/internal/zones"
|
||||
)
|
||||
|
||||
// SessionState tracks the sleep session state for a link
|
||||
type SessionState int
|
||||
|
||||
const (
|
||||
SessionStateNone SessionState = iota
|
||||
SessionStateTentative // In bedroom, stationary detected, waiting for 15-min confirmation
|
||||
SessionStateConfirmed // Sleep session confirmed (15 min stationary)
|
||||
SessionStateEnded // Session ended, waiting for morning report
|
||||
)
|
||||
|
||||
// LinkSessionState tracks the sleep session state per link
|
||||
type LinkSessionState struct {
|
||||
State SessionState
|
||||
TentativeStartTime time.Time // When tentative detection started
|
||||
ConfirmedStartTime time.Time // When sleep was confirmed (15 min after tentative)
|
||||
SessionID string
|
||||
ZoneID string
|
||||
PersonID string
|
||||
LastStationaryTime time.Time // Last time stationary was detected
|
||||
LastMotionTime time.Time // Last time motion was detected
|
||||
InBedroomZone bool
|
||||
SustainedMotionStart time.Time // When sustained motion started (for wake detection)
|
||||
}
|
||||
|
||||
// Monitor integrates the sleep analyzer with the signal processing pipeline.
|
||||
// It periodically samples breathing and motion data during sleep hours.
|
||||
type Monitor struct {
|
||||
|
|
@ -16,26 +42,39 @@ type Monitor struct {
|
|||
// Dependencies
|
||||
analyzer *SleepAnalyzer
|
||||
processorMgr *signal.ProcessorManager
|
||||
zoneMgr *zones.Manager
|
||||
storage *Storage
|
||||
|
||||
// Configuration
|
||||
sampleInterval time.Duration
|
||||
reportHour int // Hour of day to generate morning reports (0-23)
|
||||
sleepStartHour int
|
||||
sleepEndHour int
|
||||
sampleInterval time.Duration
|
||||
reportHour int // Hour of day to generate morning reports (0-23)
|
||||
sleepStartHour int
|
||||
sleepEndHour int
|
||||
sessionConfirmMinutes int // Minutes of stationary detection to confirm sleep onset (default 15)
|
||||
wakeConfirmMinutes int // Minutes of sustained motion to confirm wake (default 2)
|
||||
|
||||
// State
|
||||
running bool
|
||||
stopCh chan struct{}
|
||||
lastSample map[string]time.Time
|
||||
lastReport time.Time
|
||||
running bool
|
||||
stopCh chan struct{}
|
||||
lastSample map[string]time.Time
|
||||
lastReport time.Time
|
||||
linkSessionStates map[string]*LinkSessionState // Per-link session tracking
|
||||
firstConnectionToday bool // Track if morning summary was pushed today
|
||||
morningSummaryPushed time.Time // When morning summary was last pushed
|
||||
|
||||
// Event callbacks
|
||||
onSessionStart func(event events.SleepSessionStartEvent)
|
||||
onSessionEnd func(event events.SleepSessionEndEvent)
|
||||
}
|
||||
|
||||
// MonitorConfig holds configuration for the sleep monitor
|
||||
type MonitorConfig struct {
|
||||
SampleInterval time.Duration // How often to sample data (default 30s)
|
||||
ReportHour int // Hour to generate morning reports (default 7)
|
||||
SleepStartHour int // Start of sleep window (default 22)
|
||||
SleepEndHour int // End of sleep window (default 7)
|
||||
SampleInterval time.Duration // How often to sample data (default 30s)
|
||||
ReportHour int // Hour to generate morning reports (default 7)
|
||||
SleepStartHour int // Start of sleep window (default 22)
|
||||
SleepEndHour int // End of sleep window (default 7)
|
||||
SessionConfirmMinutes int // Minutes of stationary to confirm sleep (default 15)
|
||||
WakeConfirmMinutes int // Minutes of sustained motion to confirm wake (default 2)
|
||||
}
|
||||
|
||||
// NewMonitor creates a new sleep monitor
|
||||
|
|
@ -52,18 +91,27 @@ func NewMonitor(cfg MonitorConfig) *Monitor {
|
|||
if cfg.SleepEndHour == 0 {
|
||||
cfg.SleepEndHour = DefaultSleepEndHour
|
||||
}
|
||||
if cfg.SessionConfirmMinutes == 0 {
|
||||
cfg.SessionConfirmMinutes = 15
|
||||
}
|
||||
if cfg.WakeConfirmMinutes == 0 {
|
||||
cfg.WakeConfirmMinutes = 2
|
||||
}
|
||||
|
||||
analyzer := NewSleepAnalyzer()
|
||||
analyzer.SetSleepWindow(cfg.SleepStartHour, cfg.SleepEndHour)
|
||||
|
||||
return &Monitor{
|
||||
analyzer: analyzer,
|
||||
sampleInterval: cfg.SampleInterval,
|
||||
reportHour: cfg.ReportHour,
|
||||
sleepStartHour: cfg.SleepStartHour,
|
||||
sleepEndHour: cfg.SleepEndHour,
|
||||
stopCh: make(chan struct{}),
|
||||
lastSample: make(map[string]time.Time),
|
||||
analyzer: analyzer,
|
||||
sampleInterval: cfg.SampleInterval,
|
||||
reportHour: cfg.ReportHour,
|
||||
sleepStartHour: cfg.SleepStartHour,
|
||||
sleepEndHour: cfg.SleepEndHour,
|
||||
sessionConfirmMinutes: cfg.SessionConfirmMinutes,
|
||||
wakeConfirmMinutes: cfg.WakeConfirmMinutes,
|
||||
stopCh: make(chan struct{}),
|
||||
lastSample: make(map[string]time.Time),
|
||||
linkSessionStates: make(map[string]*LinkSessionState),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,6 +122,28 @@ func (m *Monitor) SetProcessorManager(pm *signal.ProcessorManager) {
|
|||
m.processorMgr = pm
|
||||
}
|
||||
|
||||
// SetZoneManager sets the zone manager for bedroom detection
|
||||
func (m *Monitor) SetZoneManager(zm *zones.Manager) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.zoneMgr = zm
|
||||
}
|
||||
|
||||
// SetStorage sets the storage backend for persisting sessions
|
||||
func (m *Monitor) SetStorage(s *Storage) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.storage = s
|
||||
}
|
||||
|
||||
// SetSessionCallbacks sets callbacks for session start/end events
|
||||
func (m *Monitor) SetSessionCallbacks(onStart func(events.SleepSessionStartEvent), onEnd func(events.SleepSessionEndEvent)) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.onSessionStart = onStart
|
||||
m.onSessionEnd = onEnd
|
||||
}
|
||||
|
||||
// SetReportCallback sets the callback for when reports are generated
|
||||
func (m *Monitor) SetReportCallback(cb func(linkID string, report *SleepReport)) {
|
||||
m.analyzer.SetReportCallback(cb)
|
||||
|
|
|
|||
|
|
@ -191,9 +191,6 @@ func (r *SleepReport) ToJSONMap() map[string]interface{} {
|
|||
"interruptions": r.Metrics.Interruptions,
|
||||
"longest_deep_period_mins": r.Metrics.LongestDeepPeriod.Minutes(),
|
||||
"continuity_score": r.Metrics.ContinuityScore,
|
||||
"breathing_score": r.Metrics.BreathingScore,
|
||||
"motion_score": r.Metrics.MotionScore,
|
||||
"continuity_score": r.Metrics.ContinuityScore,
|
||||
}
|
||||
|
||||
// Add timing
|
||||
|
|
|
|||
|
|
@ -43,6 +43,14 @@ type Blob struct {
|
|||
// Trail holds the last TrailMaxLen positions (newest last).
|
||||
Trail [][3]float64
|
||||
|
||||
// Identity fields (populated by BLE-to-blob matching)
|
||||
PersonID string `json:"person_id,omitempty"` // UUID from BLE registry
|
||||
PersonLabel string `json:"person_label,omitempty"` // Display name
|
||||
PersonColor string `json:"person_color,omitempty"` // Hex color for dashboard
|
||||
IdentityConfidence float64 `json:"identity_confidence,omitempty"` // Match confidence [0..1]
|
||||
IdentitySource string `json:"identity_source,omitempty"` // "ble_triangulation", "ble_only", or ""
|
||||
IdentityLastSeen time.Time `json:"-"` // Last time identity was confirmed
|
||||
|
||||
ukf *UKF // internal — nil in copies returned to callers
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue