From c75428d488459ae4cb49c4216c1b4c651d13ba8b Mon Sep 17 00:00:00 2001 From: jedarden Date: Mon, 6 Apr 2026 08:52:49 -0400 Subject: [PATCH] ci: set VERSION to 0.1.0 for image build --- .marathon/start.sh | 5 +- VERSION | 2 +- dashboard/js/viz3d.js | 362 ++++++++++++++++++ mothership/internal/analytics/anomaly.go | 330 +++++++++++++--- mothership/internal/analytics/handler.go | 139 +++++++ .../internal/diagnostics/linkweather.go | 71 +++- .../internal/diagnostics/linkweather_test.go | 3 +- mothership/internal/fleet/handler.go | 76 +++- mothership/internal/fleet/manager.go | 319 ++++++++++++++- mothership/internal/fleet/registry.go | 161 +++++++- mothership/internal/localization/fusion.go | 69 +++- mothership/internal/localization/grid.go | 22 +- mothership/internal/sleep/analyzer.go | 201 ++++++++++ mothership/internal/sleep/integration.go | 108 +++++- mothership/internal/sleep/report.go | 3 - mothership/internal/tracker/tracker.go | 8 + 16 files changed, 1765 insertions(+), 114 deletions(-) diff --git a/.marathon/start.sh b/.marathon/start.sh index 71622a6..0d22263 100755 --- a/.marathon/start.sh +++ b/.marathon/start.sh @@ -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' && \ diff --git a/VERSION b/VERSION index 845639e..6e8bf73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.4 +0.1.0 diff --git a/dashboard/js/viz3d.js b/dashboard/js/viz3d.js index 663bbc6..c85f0a5 100644 --- a/dashboard/js/viz3d.js +++ b/dashboard/js/viz3d.js @@ -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, }; })(); diff --git a/mothership/internal/analytics/anomaly.go b/mothership/internal/analytics/anomaly.go index 9e7b437..4375794 100644 --- a/mothership/internal/analytics/anomaly.go +++ b/mothership/internal/analytics/anomaly.go @@ -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 diff --git a/mothership/internal/analytics/handler.go b/mothership/internal/analytics/handler.go index d562131..314e086 100644 --- a/mothership/internal/analytics/handler.go +++ b/mothership/internal/analytics/handler.go @@ -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"}) +} diff --git a/mothership/internal/diagnostics/linkweather.go b/mothership/internal/diagnostics/linkweather.go index 9d87244..2d5455e 100644 --- a/mothership/internal/diagnostics/linkweather.go +++ b/mothership/internal/diagnostics/linkweather.go @@ -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) diff --git a/mothership/internal/diagnostics/linkweather_test.go b/mothership/internal/diagnostics/linkweather_test.go index f8f663b..3a08c0b 100644 --- a/mothership/internal/diagnostics/linkweather_test.go +++ b/mothership/internal/diagnostics/linkweather_test.go @@ -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%) diff --git a/mothership/internal/fleet/handler.go b/mothership/internal/fleet/handler.go index c398030..2675f92 100644 --- a/mothership/internal/fleet/handler.go +++ b/mothership/internal/fleet/handler.go @@ -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) } diff --git a/mothership/internal/fleet/manager.go b/mothership/internal/fleet/manager.go index 7336a67..a7545ba 100644 --- a/mothership/internal/fleet/manager.go +++ b/mothership/internal/fleet/manager.go @@ -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 +} diff --git a/mothership/internal/fleet/registry.go b/mothership/internal/fleet/registry.go index cfba46a..67a382b 100644 --- a/mothership/internal/fleet/registry.go +++ b/mothership/internal/fleet/registry.go @@ -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 } diff --git a/mothership/internal/localization/fusion.go b/mothership/internal/localization/fusion.go index f379662..e97d79f 100644 --- a/mothership/internal/localization/fusion.go +++ b/mothership/internal/localization/fusion.go @@ -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++ } diff --git a/mothership/internal/localization/grid.go b/mothership/internal/localization/grid.go index f8da218..f9e245b 100644 --- a/mothership/internal/localization/grid.go +++ b/mothership/internal/localization/grid.go @@ -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() diff --git a/mothership/internal/sleep/analyzer.go b/mothership/internal/sleep/analyzer.go index 4361a4c..df6ec4d 100644 --- a/mothership/internal/sleep/analyzer.go +++ b/mothership/internal/sleep/analyzer.go @@ -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) } diff --git a/mothership/internal/sleep/integration.go b/mothership/internal/sleep/integration.go index b3a6218..05b2eef 100644 --- a/mothership/internal/sleep/integration.go +++ b/mothership/internal/sleep/integration.go @@ -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) diff --git a/mothership/internal/sleep/report.go b/mothership/internal/sleep/report.go index df7ced3..d607185 100644 --- a/mothership/internal/sleep/report.go +++ b/mothership/internal/sleep/report.go @@ -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 diff --git a/mothership/internal/tracker/tracker.go b/mothership/internal/tracker/tracker.go index 578fde6..e0f593a 100644 --- a/mothership/internal/tracker/tracker.go +++ b/mothership/internal/tracker/tracker.go @@ -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 }