ci: set VERSION to 0.1.0 for image build

This commit is contained in:
jedarden 2026-04-06 08:52:49 -04:00
parent 0f5db551e5
commit c75428d488
16 changed files with 1765 additions and 114 deletions

View file

@ -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' && \

View file

@ -1 +1 @@
0.1.4
0.1.0

View file

@ -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,
};
})();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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