diff --git a/dashboard/index.html b/dashboard/index.html index 41bd37b..dd0be08 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -188,6 +188,65 @@ padding-top: 12px; } + .link-section h3 { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.5); + margin-bottom: 8px; + } + + /* Pattern visualization controls */ + .pattern-checkbox { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + cursor: pointer; + font-size: 12px; + color: rgba(255, 255, 255, 0.7); + } + + .pattern-checkbox:hover { + color: rgba(255, 255, 255, 0.9); + } + + .pattern-checkbox input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: #4fc3f7; + cursor: pointer; + } + + .pattern-filter { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + font-size: 11px; + } + + .pattern-filter label { + color: rgba(255, 255, 255, 0.5); + } + + .pattern-filter select { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 4px; + color: #e0e0e0; + padding: 4px 8px; + font-size: 11px; + cursor: pointer; + } + + .pattern-filter select:focus { + outline: none; + border-color: #4fc3f7; + } + .link-item { padding: 6px 8px; margin-bottom: 4px; @@ -1569,6 +1628,31 @@
No links active
+ diff --git a/dashboard/js/app.js b/dashboard/js/app.js index e051f9d..12ff5a3 100644 --- a/dashboard/js/app.js +++ b/dashboard/js/app.js @@ -1237,4 +1237,24 @@ refreshNodeList: updateNodeList, refreshLinkList: updateLinkList }; + + // ============================================ + // Crowd Flow Visualization Controls + // Global wrappers for HTML onchange handlers -> Viz3D module + // ============================================ + window.toggleFlowLayer = function(visible) { + Viz3D.setFlowLayerVisible(visible); + }; + + window.toggleDwellLayer = function(visible) { + Viz3D.setDwellLayerVisible(visible); + }; + + window.toggleCorridorLayer = function(visible) { + Viz3D.setCorridorLayerVisible(visible); + }; + + window.setFlowTimeFilter = function(value) { + Viz3D.setFlowTimeFilter(value); + }; })(); diff --git a/mothership/cmd/mothership/main_phase6.go b/mothership/cmd/mothership/main_phase6.go index a5eb8a9..86944c5 100644 --- a/mothership/cmd/mothership/main_phase6.go +++ b/mothership/cmd/mothership/main_phase6.go @@ -31,6 +31,7 @@ import ( "github.com/spaxel/mothership/internal/mqtt" "github.com/spaxel/mothership/internal/notify" "github.com/spaxel/mothership/internal/ota" + "github.com/spaxel/mothership/internal/prediction" "github.com/spaxel/mothership/internal/provisioning" "github.com/spaxel/mothership/internal/recorder" "github.com/spaxel/mothership/internal/replay" @@ -182,6 +183,31 @@ func main() { fallDetector := falldetect.NewDetector() log.Printf("[INFO] Fall detector initialized") + // Phase 6: Prediction module for presence prediction + var predictionStore *prediction.ModelStore + var predictionHistory *prediction.HistoryUpdater + var predictionPredictor *prediction.Predictor + predictionStore, err = prediction.NewModelStore(filepath.Join(cfg.DataDir, "prediction.db")) + if err != nil { + log.Printf("[WARN] Failed to open prediction store: %v", err) + } else { + defer predictionStore.Close() + log.Printf("[INFO] Prediction store at %s", filepath.Join(cfg.DataDir, "prediction.db")) + + // Create history updater + predictionHistory = prediction.NewHistoryUpdater(predictionStore) + + // Load stored person zone positions + if err := predictionHistory.LoadStoredPositions(); err != nil { + log.Printf("[WARN] Failed to load stored prediction positions: %v", err) + } + + // Create predictor + predictionPredictor = prediction.NewPredictor(predictionStore) + + log.Printf("[INFO] Presence prediction initialized") + } + // Phase 6: Notification service notifyService, err := notify.NewService(filepath.Join(cfg.DataDir, "notify.db")) if err != nil { @@ -670,6 +696,11 @@ func main() { automationEngine.UpdateZoneDwellTracking(event.BlobID, event.ToZone, time.Now()) } } + + // Record zone transition for presence prediction + if predictionHistory != nil && personID != "" { + predictionHistory.PersonZoneChange(personID, event.FromZone, event.ToZone, event.BlobID, time.Now()) + } }) } @@ -802,6 +833,74 @@ func main() { log.Printf("[INFO] Flow analytics background tasks started (prune: 24h, corridors: 7d)") } + // Phase 6: Prediction provider wiring and update loop + if predictionPredictor != nil && predictionHistory != nil { + // Wire zone provider + if zonesMgr != nil { + predictionPredictor.SetZoneProvider(&predictionZoneAdapter{mgr: zonesMgr}) + } + + // Wire person provider + if bleRegistry != nil { + predictionPredictor.SetPersonProvider(&predictionPersonAdapter{registry: bleRegistry}) + } + + // Wire position provider + predictionPredictor.SetPositionProvider(prediction.NewPositionAdapter(predictionHistory)) + + // Wire MQTT client for prediction publishing + if mqttClient != nil && mqttClient.IsConnected() { + predictionPredictor.SetMQTTClient(&predictionMQTTAdapter{client: mqttClient}, "") + } + + // Start periodic prediction update loop (every 60 seconds) + go func() { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + + // Run initial prediction after 5 seconds + time.Sleep(5 * time.Second) + predictionPredictor.UpdatePredictions() + log.Printf("[INFO] Prediction: initial predictions computed") + + // Publish prediction sensors for each person + if mqttClient != nil && mqttClient.IsConnected() && bleRegistry != nil { + people, _ := bleRegistry.GetPeople() + for _, person := range people { + mqttClient.PublishPredictionSensors(person.ID, person.Name) + } + } + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + predictionPredictor.UpdatePredictions() + + // Publish predictions to MQTT + if mqttClient != nil && mqttClient.IsConnected() { + predictions := predictionPredictor.GetPredictions() + for _, pred := range predictions { + zoneName := pred.PredictedNextZoneName + if zoneName == "" { + zoneName = pred.PredictedNextZoneID + } + mqttClient.UpdatePredictionState( + pred.PersonID, + zoneName, + pred.DataConfidence, + pred.PredictionConfidence, + pred.EstimatedTransitionMinutes, + ) + } + } + } + } + }() + log.Printf("[INFO] Prediction update loop started (interval: 60s)") + } + // Fleet REST API fleetHandler := fleet.NewHandler(fleetMgr) fleetHandler.RegisterRoutes(r) @@ -1327,6 +1426,44 @@ func main() { analyticsHandler.RegisterRoutes(r) } + // Phase 6: Prediction REST API + if predictionPredictor != nil { + r.Get("/api/predictions", func(w http.ResponseWriter, r *http.Request) { + predictions := predictionPredictor.GetPredictions() + writeJSON(w, predictions) + }) + + r.Get("/api/predictions/stats", func(w http.ResponseWriter, r *http.Request) { + if predictionHistory == nil { + http.Error(w, "prediction history not available", http.StatusServiceUnavailable) + return + } + count, dataAge, err := predictionHistory.GetTransitionStats() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]interface{}{ + "transition_count": count, + "data_age_days": dataAge.Hours() / 24, + "minimum_data_age": prediction.MinimumDataAge.Hours() / 24, + "has_minimum_data": dataAge >= prediction.MinimumDataAge, + }) + }) + + r.Post("/api/predictions/recompute", func(w http.ResponseWriter, r *http.Request) { + if predictionHistory == nil { + http.Error(w, "prediction history not available", http.StatusServiceUnavailable) + return + } + if err := predictionHistory.ForceRecompute(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "recompute_started"}) + }) + } + // Phase 6: Learning feedback REST API if feedbackStore != nil { learningHandler := learning.NewHandler(feedbackStore, feedbackProcessor, accuracyComputer) @@ -1568,3 +1705,132 @@ func (n *notifySenderAdapter) SendViaChannel(channelType string, title, body str return n.service.Send(notif) } +// Prediction provider adapters + +type predictionZoneAdapter struct { + mgr *zones.Manager +} + +func (z *predictionZoneAdapter) GetZone(id string) (string, bool) { + zone := z.mgr.GetZone(id) + if zone == nil { + return "", false + } + return zone.Name, true +} + +type predictionPersonAdapter struct { + registry *ble.Registry +} + +func (p *predictionPersonAdapter) GetPerson(id string) (string, string, bool) { + person, err := p.registry.GetPerson(id) + if err != nil { + return "", "", false + } + return person.Name, person.Color, true +} + +func (p *predictionPersonAdapter) GetAllPeople() ([]struct { + ID string + Name string + Color string +}, error) { + people, err := p.registry.GetPeople() + if err != nil { + return nil, err + } + result := make([]struct { + ID string + Name string + Color string + }, len(people)) + for i, person := range people { + result[i] = struct { + ID string + Name string + Color string + }{ID: person.ID, Name: person.Name, Color: person.Color} + } + return result, nil +} + +type predictionMQTTAdapter struct { + client *mqtt.Client +} + +func (m *predictionMQTTAdapter) Publish(topic string, payload []byte) error { + return m.client.Publish(topic, payload) +} + +func (m *predictionMQTTAdapter) IsConnected() bool { + return m.client.IsConnected() +} + +// Prediction provider adapters + +type predictionZoneAdapter struct { + mgr *zones.Manager +} + +func (z *predictionZoneAdapter) GetZone(id string) (string, bool) { + zone := z.mgr.GetZone(id) + if zone == nil { + return "", false + } + return zone.Name, true +} + +type predictionPersonAdapter struct { + registry *ble.Registry +} + +func (p *predictionPersonAdapter) GetPerson(id string) (string, string, bool) { + person, err := p.registry.GetPerson(id) + if err != nil { + return "", "", false + } + return person.Name, person.Color, true +} + +func (p *predictionPersonAdapter) GetAllPeople() ([]struct { + ID string + Name string + Color string +}, error) { + people, err := p.registry.GetPeople() + if err != nil { + return nil, err + } + + result := make([]struct { + ID string + Name string + Color string + }, len(people)) + for i, person := range people { + result[i] = struct { + ID string + Name string + Color string + }{ + ID: person.ID, + Name: person.Name, + Color: person.Color, + } + } + return result, nil +} + +type predictionMQTTAdapter struct { + client *mqtt.Client +} + +func (m *predictionMQTTAdapter) Publish(topic string, payload []byte) error { + return m.client.Publish(topic, payload) +} + +func (m *predictionMQTTAdapter) IsConnected() bool { + return m.client.IsConnected() +} + diff --git a/mothership/internal/analytics/anomaly.go b/mothership/internal/analytics/anomaly.go new file mode 100644 index 0000000..9e7b437 --- /dev/null +++ b/mothership/internal/analytics/anomaly.go @@ -0,0 +1,1249 @@ +// Package analytics provides anomaly detection based on learned normal behaviour patterns. +package analytics + +import ( + "database/sql" + "fmt" + "log" + "math" + "os" + "path/filepath" + "sync" + "time" + + "github.com/google/uuid" + "github.com/spaxel/mothership/internal/events" + + _ "modernc.org/sqlite" +) + +// NormalBehaviourSlot represents expected behaviour for a specific hour_of_week and zone. +type NormalBehaviourSlot struct { + HourOfWeek int `json:"hour_of_week"` // 0-167 + ZoneID string `json:"zone_id"` + ExpectedOccupancy float64 ` json:"expected_occupancy"` // 0.0-1.0, fraction of samples with occupancy + TypicalPersonCount float64 `json:"typical_person_count"` // Mean person count + SampleCount int `json:"sample_count"` + TypicalBLEDevices map[string]float64 `json:"typical_ble_devices,omitempty"` // MAC -> frequency (0.0-1.0) +} + +// DwellBehaviourSlot represents expected dwell duration for a person in a zone at a specific hour. +type DwellBehaviourSlot struct { + HourOfWeek int `json:"hour_of_week"` + ZoneID string `json:"zone_id"` + PersonID string `json:"person_id"` + MeanDwellDuration time.Duration `json:"mean_dwell_duration"` + StdDwellDuration time.Duration `json:"std_dwell_duration"` + SampleCount int `json:"sample_count"` +} + +// AnomalyScoreConfig holds configurable thresholds for anomaly scoring. +type AnomalyScoreConfig struct { + // Unusual hour presence + UnusualHourScore float64 `json:"unusual_hour_score"` // Default: 0.7 + UnusualHourScoreSecurity float64 `json:"unusual_hour_score_security"` // Default: 0.9 + LateNightMultiplier float64 `json:"late_night_multiplier"` // Default: 1.5 (00:00-06:00) + + // Unknown BLE device + UnknownBLEScore float64 `json:"unknown_ble_score"` // Default: 0.5 + UnknownBLEScoreSecurity float64 `json:"unknown_ble_score_security"` // Default: 0.8 + SeenOnceScore float64 `json:"seen_once_score"` // Default: 0.3 + CloseRangeRSSIThreshold int `json:"close_range_rssi_threshold"` // Default: -60 dBm + + // Motion during away + MotionDuringAwayScore float64 `json:"motion_during_away_score"` // Default: 0.95 + + // Unusual dwell duration + UnusualDwellScore float64 `json:"unusual_dwell_score"` // Default: 0.4 + DwellMultiplierThreshold float64 `json:"dwell_multiplier_threshold"` // Default: 5.0 + + // Alert thresholds + AlertThresholdNormal float64 `json:"alert_threshold_normal"` // Default: 0.6 + AlertThresholdSecurity float64 `json:"alert_threshold_security"` // Default: 0.4 + + // Auto-away/disarm + AutoAwayDuration time.Duration `json:"auto_away_duration"` // Default: 15 minutes + AutoDisarmRSSIThreshold int `json:"auto_disarm_rssi_threshold"` // Default: -70 dBm + ManualOverrideDuration time.Duration `json:"manual_override_duration"` // Default: 30 minutes +} + +// DefaultAnomalyScoreConfig returns default configuration. +func DefaultAnomalyScoreConfig() AnomalyScoreConfig { + return AnomalyScoreConfig{ + UnusualHourScore: 0.7, + UnusualHourScoreSecurity: 0.9, + LateNightMultiplier: 1.5, + UnknownBLEScore: 0.5, + UnknownBLEScoreSecurity: 0.8, + SeenOnceScore: 0.3, + CloseRangeRSSIThreshold: -60, + MotionDuringAwayScore: 0.95, + UnusualDwellScore: 0.4, + DwellMultiplierThreshold: 5.0, + AlertThresholdNormal: 0.6, + AlertThresholdSecurity: 0.4, + AutoAwayDuration: 15 * time.Minute, + AutoDisarmRSSIThreshold: -70, + ManualOverrideDuration: 30 * time.Minute, + } +} + +// Detector detects anomalies based on learned normal behaviour. +type Detector struct { + mu sync.RWMutex + db *sql.DB + config AnomalyScoreConfig + + // Normal behaviour model (loaded from DB) + behaviourSlots map[string]*NormalBehaviourSlot // key: "hour-zone" + dwellSlots map[string]*DwellBehaviourSlot // key: "hour-zone-person" + + // Active anomaly tracking + activeAnomalies map[string]*events.AnomalyEvent // id -> event + anomalyHistory []*events.AnomalyEvent + + // Pending alert timers + pendingAlerts map[string]*alertTimerState + + // Model state + learningStartTime time.Time + modelReady bool + modelReadyAt time.Time + + // Registered devices and people + registeredDevices map[string]bool // MAC -> registered + registeredPeople map[string]string // person_id -> name + deviceFirstSeen map[string]time.Time // MAC -> first seen time + + // Providers + zoneProvider ZoneProvider + personProvider PersonProvider + deviceProvider DeviceProvider + positionProvider PositionProvider + alertHandler AlertHandler + + // Callbacks + onAnomaly func(event events.AnomalyEvent) + onModeChange func(event events.SystemModeChangeEvent) +} + +// ZoneProvider provides zone information. +type ZoneProvider interface { + GetZoneName(zoneID string) string + GetZoneOccupancy(zoneID string) (count int, blobIDs []int) +} + +// PersonProvider provides person information. +type PersonProvider interface { + GetPersonDevices(personID string) ([]string, error) + GetAllRegisteredDevices() (map[string]string, error) // MAC -> person_id + GetPersonName(personID string) string +} + +// DeviceProvider provides device information. +type DeviceProvider interface { + IsDeviceRegistered(mac string) bool + IsDeviceSeenBefore(mac string) bool + GetDeviceName(mac string) string +} + +// PositionProvider provides position for blobs. +type PositionProvider interface { + GetBlobPosition(blobID int) (x, y, z float64, ok bool) +} + +// AlertHandler handles alert delivery. +type AlertHandler interface { + SendAlert(event events.AnomalyEvent, immediate bool) error + SendWebhook(event events.AnomalyEvent, immediate bool) error + SendEscalation(event events.AnomalyEvent) error +} + +type alertTimerState struct { + alertTimer *time.Timer + webhookTimer *time.Timer + escalationTimer *time.Timer + anomalyID string +} + +// NewDetector creates a new anomaly detector. +func NewDetector(dbPath string, config AnomalyScoreConfig) (*Detector, error) { + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + return nil, fmt.Errorf("create data dir: %w", err) + } + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + db.SetMaxOpenConns(1) + + d := &Detector{ + db: db, + config: config, + behaviourSlots: make(map[string]*NormalBehaviourSlot), + dwellSlots: make(map[string]*DwellBehaviourSlot), + activeAnomalies: make(map[string]*events.AnomalyEvent), + pendingAlerts: make(map[string]*alertTimerState), + registeredDevices: make(map[string]bool), + registeredPeople: make(map[string]string), + deviceFirstSeen: make(map[string]time.Time), + } + + if err := d.migrate(); err != nil { + db.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + + if err := d.loadBehaviourModel(); err != nil { + log.Printf("[WARN] Failed to load behaviour model: %v", err) + } + + if err := d.loadLearningState(); err != nil { + log.Printf("[WARN] Failed to load learning state: %v", err) + } + + return d, nil +} + +func (d *Detector) migrate() error { + _, err := d.db.Exec(` + CREATE TABLE IF NOT EXISTS behaviour_slots ( + hour_of_week INTEGER NOT NULL, + zone_id TEXT NOT NULL, + expected_occupancy REAL NOT NULL DEFAULT 0, + typical_person_count REAL NOT NULL DEFAULT 0, + sample_count INTEGER NOT NULL DEFAULT 0, + typical_ble_devices TEXT NOT NULL DEFAULT '{}', + PRIMARY KEY (hour_of_week, zone_id) + ); + + CREATE TABLE IF NOT EXISTS dwell_slots ( + hour_of_week INTEGER NOT NULL, + zone_id TEXT NOT NULL, + person_id TEXT NOT NULL, + mean_dwell_ns INTEGER NOT NULL DEFAULT 0, + std_dwell_ns INTEGER NOT NULL DEFAULT 0, + sample_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (hour_of_week, zone_id, person_id) + ); + + CREATE TABLE IF NOT EXISTS occupancy_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hour_of_week INTEGER NOT NULL, + zone_id TEXT NOT NULL, + person_count INTEGER NOT NULL, + ble_devices TEXT NOT NULL DEFAULT '[]', + timestamp INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS dwell_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hour_of_week INTEGER NOT NULL, + zone_id TEXT NOT NULL, + person_id TEXT NOT NULL, + dwell_ns INTEGER NOT NULL, + timestamp INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS anomaly_events ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + score REAL NOT NULL, + description TEXT NOT NULL, + timestamp INTEGER NOT NULL, + zone_id TEXT, + zone_name TEXT, + blob_id INTEGER, + person_id TEXT, + person_name TEXT, + device_mac TEXT, + device_name TEXT, + position_x REAL, + position_y REAL, + position_z REAL, + hour_of_week INTEGER, + expected_occupancy REAL, + dwell_duration_ns INTEGER, + expected_dwell_ns INTEGER, + rssi_dbm INTEGER, + seen_before INTEGER, + acknowledged INTEGER NOT NULL DEFAULT 0, + acknowledged_at INTEGER, + feedback TEXT, + alert_sent INTEGER NOT NULL DEFAULT 0, + webhook_sent INTEGER NOT NULL DEFAULT 0, + escalation_sent INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS learning_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS device_first_seen ( + mac TEXT PRIMARY KEY, + first_seen_ns INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_occupancy_samples_time ON occupancy_samples(timestamp); + CREATE INDEX IF NOT EXISTS idx_dwell_samples_time ON dwell_samples(timestamp); + CREATE INDEX IF NOT EXISTS idx_anomaly_events_time ON anomaly_events(timestamp); + `) + return err +} + +func (d *Detector) loadBehaviourModel() error { + // Load behaviour slots + rows, err := d.db.Query(` + SELECT hour_of_week, zone_id, expected_occupancy, typical_person_count, sample_count, typical_ble_devices + FROM behaviour_slots + `) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + slot := &NormalBehaviourSlot{ + TypicalBLEDevices: make(map[string]float64), + } + var bleDevicesJSON string + if err := rows.Scan(&slot.HourOfWeek, &slot.ZoneID, &slot.ExpectedOccupancy, + &slot.TypicalPersonCount, &slot.SampleCount, &bleDevicesJSON); err != nil { + continue + } + // Parse BLE devices JSON + if bleDevicesJSON != "" && bleDevicesJSON != "{}" { + var devices map[string]float64 + if err := jsonUnmarshal(bleDevicesJSON, &devices); err == nil { + slot.TypicalBLEDevices = devices + } + } + key := fmt.Sprintf("%d-%s", slot.HourOfWeek, slot.ZoneID) + d.behaviourSlots[key] = slot + } + + // Load dwell slots + dwellRows, err := d.db.Query(` + SELECT hour_of_week, zone_id, person_id, mean_dwell_ns, std_dwell_ns, sample_count + FROM dwell_slots + `) + if err != nil { + return err + } + defer dwellRows.Close() + + for dwellRows.Next() { + slot := &DwellBehaviourSlot{} + var meanNS, stdNS int64 + if err := dwellRows.Scan(&slot.HourOfWeek, &slot.ZoneID, &slot.PersonID, + &meanNS, &stdNS, &slot.SampleCount); err != nil { + continue + } + slot.MeanDwellDuration = time.Duration(meanNS) + slot.StdDwellDuration = time.Duration(stdNS) + key := fmt.Sprintf("%d-%s-%s", slot.HourOfWeek, slot.ZoneID, slot.PersonID) + d.dwellSlots[key] = slot + } + + return nil +} + +func (d *Detector) loadLearningState() error { + var startNS int64 + err := d.db.QueryRow(`SELECT value FROM learning_state WHERE key = 'learning_start'`).Scan(&startNS) + if err == sql.ErrNoRows { + // Initialize learning start time + d.learningStartTime = time.Now() + d.db.Exec(`INSERT INTO learning_state (key, value) VALUES ('learning_start', ?)`, time.Now().UnixNano()) + return nil + } + if err != nil { + return err + } + + d.learningStartTime = time.Unix(0, startNS) + + // Check if 7 days have passed + if time.Since(d.learningStartTime) >= 7*24*time.Hour { + d.modelReady = true + d.modelReadyAt = d.learningStartTime.Add(7 * 24 * time.Hour) + } + + // Load device first seen times + deviceRows, err := d.db.Query(`SELECT mac, first_seen_ns FROM device_first_seen`) + if err != nil { + return err + } + defer deviceRows.Close() + + for deviceRows.Next() { + var mac string + var firstSeenNS int64 + if err := deviceRows.Scan(&mac, &firstSeenNS); err != nil { + continue + } + d.deviceFirstSeen[mac] = time.Unix(0, firstSeenNS) + } + + return nil +} + +// Close closes the database. +func (d *Detector) Close() error { + return d.db.Close() +} + +// SetZoneProvider sets the zone provider. +func (d *Detector) SetZoneProvider(p ZoneProvider) { + d.mu.Lock() + d.zoneProvider = p + d.mu.Unlock() +} + +// SetPersonProvider sets the person provider. +func (d *Detector) SetPersonProvider(p PersonProvider) { + d.mu.Lock() + d.personProvider = p + d.mu.Unlock() +} + +// SetDeviceProvider sets the device provider. +func (d *Detector) SetDeviceProvider(p DeviceProvider) { + d.mu.Lock() + d.deviceProvider = p + d.mu.Unlock() +} + +// SetPositionProvider sets the position provider. +func (d *Detector) SetPositionProvider(p PositionProvider) { + d.mu.Lock() + d.positionProvider = p + d.mu.Unlock() +} + +// SetAlertHandler sets the alert handler. +func (d *Detector) SetAlertHandler(h AlertHandler) { + d.mu.Lock() + d.alertHandler = h + d.mu.Unlock() +} + +// SetOnAnomaly sets callback for anomaly events. +func (d *Detector) SetOnAnomaly(cb func(event events.AnomalyEvent)) { + d.mu.Lock() + d.onAnomaly = cb + d.mu.Unlock() +} + +// SetOnModeChange sets callback for mode change events. +func (d *Detector) SetOnModeChange(cb func(event events.SystemModeChangeEvent)) { + d.mu.Lock() + d.onModeChange = cb + d.mu.Unlock() +} + +// SetRegisteredDevices sets the list of registered BLE devices. +func (d *Detector) SetRegisteredDevices(devices []string) { + d.mu.Lock() + defer d.mu.Unlock() + + d.registeredDevices = make(map[string]bool) + for _, mac := range devices { + d.registeredDevices[mac] = true + } +} + +// IsModelReady returns true if 7 days of learning have passed. +func (d *Detector) IsModelReady() bool { + d.mu.RLock() + defer d.mu.RUnlock() + return d.modelReady +} + +// GetLearningProgress returns the fraction of learning completed (0.0-1.0). +func (d *Detector) GetLearningProgress() float64 { + d.mu.RLock() + defer d.mu.RUnlock() + + if d.modelReady { + return 1.0 + } + + elapsed := time.Since(d.learningStartTime) + total := 7 * 24 * time.Hour + progress := float64(elapsed) / float64(total) + if progress > 1.0 { + progress = 1.0 + } + return progress +} + +// ProcessOccupancy records an occupancy observation and checks for unusual hour anomalies. +func (d *Detector) ProcessOccupancy(zoneID string, personCount int, bleDevices []string, isSecurityMode bool) *events.AnomalyEvent { + d.mu.Lock() + defer d.mu.Unlock() + + now := time.Now() + hourOfWeek := getHourOfWeek(now) + + // Record the sample + d.recordOccupancySample(hourOfWeek, zoneID, personCount, bleDevices, now) + + // Check for anomaly (only if model is ready, or if in security mode) + if !d.modelReady && !isSecurityMode { + return nil + } + + key := fmt.Sprintf("%d-%s", hourOfWeek, zoneID) + slot, exists := d.behaviourSlots[key] + + if !exists || slot.SampleCount < 10 { + // Not enough data for this slot + return nil + } + + // Check if this is an unusual hour (low expected occupancy but we see people) + if personCount > 0 && slot.ExpectedOccupancy < 0.1 { + score := d.config.UnusualHourScore + if isSecurityMode { + score = d.config.UnusualHourScoreSecurity + } + + // Apply late night multiplier (00:00-06:00) + hour := now.Hour() + if hour >= 0 && hour < 6 { + score *= d.config.LateNightMultiplier + if score > 1.0 { + score = 1.0 + } + } + + // Get zone name + zoneName := zoneID + if d.zoneProvider != nil { + zoneName = d.zoneProvider.GetZoneName(zoneID) + } + + // Create anomaly event + event := events.AnomalyEvent{ + ID: uuid.New().String(), + Type: events.AnomalyUnusualHour, + Score: score, + Description: fmt.Sprintf("Motion detected in %s at %s (unusual hour)", zoneName, now.Format("3:04pm")), + Timestamp: now, + ZoneID: zoneID, + ZoneName: zoneName, + HourOfWeek: hourOfWeek, + ExpectedOccupancy: slot.ExpectedOccupancy, + } + + return d.createAnomaly(&event, isSecurityMode) + } + + return nil +} + +// ProcessBLEDevice checks for unknown BLE device anomalies. +func (d *Detector) ProcessBLEDevice(mac string, rssi int, isSecurityMode bool) *events.AnomalyEvent { + d.mu.Lock() + defer d.mu.Unlock() + + now := time.Now() + + // Track first seen time for this device + if _, exists := d.deviceFirstSeen[mac]; !exists { + d.deviceFirstSeen[mac] = now + d.db.Exec(`INSERT OR REPLACE INTO device_first_seen (mac, first_seen_ns) VALUES (?, ?)`, + mac, now.UnixNano()) + } + + // Check if device is registered + if d.registeredDevices[mac] { + return nil + } + + // Check if close range + if rssi < d.config.CloseRangeRSSIThreshold { + return nil // Not close enough to be concerning + } + + // Check if device was seen before + seenBefore := false + if d.deviceProvider != nil { + seenBefore = d.deviceProvider.IsDeviceSeenBefore(mac) + } + + // Calculate score + var score float64 + if !seenBefore { + // Never seen before + score = d.config.UnknownBLEScore + if isSecurityMode { + score = d.config.UnknownBLEScoreSecurity + } + } else { + // Seen before but not registered + score = d.config.SeenOnceScore + } + + if score < d.getAlertThreshold(isSecurityMode) { + return nil + } + + // Get device name + deviceName := mac + if d.deviceProvider != nil { + deviceName = d.deviceProvider.GetDeviceName(mac) + } + + event := events.AnomalyEvent{ + ID: uuid.New().String(), + Type: events.AnomalyUnknownBLE, + Score: score, + Description: fmt.Sprintf("Unknown device detected nearby: %s (RSSI: %d dBm)", deviceName, rssi), + Timestamp: now, + DeviceMAC: mac, + DeviceName: deviceName, + RSSIdBm: rssi, + SeenBefore: seenBefore, + } + + return d.createAnomaly(&event, isSecurityMode) +} + +// ProcessMotionDuringAway checks for motion when system is in away mode. +func (d *Detector) ProcessMotionDuringAway(zoneID string, blobID int, isSecurityMode bool) *events.AnomalyEvent { + d.mu.Lock() + defer d.mu.Unlock() + + now := time.Now() + + // This anomaly always fires regardless of model training status + score := d.config.MotionDuringAwayScore + + // Get zone name + zoneName := zoneID + if d.zoneProvider != nil { + zoneName = d.zoneProvider.GetZoneName(zoneID) + } + + // Get position + var pos events.Position + if d.positionProvider != nil { + x, y, z, ok := d.positionProvider.GetBlobPosition(blobID) + if ok { + pos = events.Position{X: x, Y: y, Z: z} + } + } + + event := events.AnomalyEvent{ + ID: uuid.New().String(), + Type: events.AnomalyMotionDuringAway, + Score: score, + Description: fmt.Sprintf("Motion detected in %s while everyone is away", zoneName), + Timestamp: now, + ZoneID: zoneID, + ZoneName: zoneName, + BlobID: blobID, + Position: pos, + } + + return d.createAnomaly(&event, isSecurityMode) +} + +// ProcessDwellDuration checks for unusual dwell duration. +func (d *Detector) ProcessDwellDuration(zoneID, personID string, dwellDuration time.Duration, isSecurityMode bool, isFallDetected bool) *events.AnomalyEvent { + d.mu.Lock() + defer d.mu.Unlock() + + // Don't report if fall is already detected (fall detection takes priority) + if isFallDetected { + return nil + } + + now := time.Now() + hourOfWeek := getHourOfWeek(now) + + // Record the sample + d.recordDwellSample(hourOfWeek, zoneID, personID, dwellDuration, now) + + // Only check if model is ready (this anomaly requires learned patterns) + if !d.modelReady { + return nil + } + + key := fmt.Sprintf("%d-%s-%s", hourOfWeek, zoneID, personID) + slot, exists := d.dwellSlots[key] + + if !exists || slot.SampleCount < 5 { + return nil + } + + // Check if dwelling for > 5x mean + if dwellDuration > time.Duration(float64(slot.MeanDwellDuration)*d.config.DwellMultiplierThreshold) { + score := d.config.UnusualDwellScore + + // Get names + zoneName := zoneID + if d.zoneProvider != nil { + zoneName = d.zoneProvider.GetZoneName(zoneID) + } + personName := personID + if d.personProvider != nil { + personName = d.personProvider.GetPersonName(personID) + } + + event := events.AnomalyEvent{ + ID: uuid.New().String(), + Type: events.AnomalyUnusualDwell, + Score: score, + Description: fmt.Sprintf("%s in %s for longer than usual (%.0f minutes)", personName, zoneName, dwellDuration.Minutes()), + Timestamp: now, + ZoneID: zoneID, + ZoneName: zoneName, + PersonID: personID, + PersonName: personName, + DwellDuration: dwellDuration, + ExpectedDwell: slot.MeanDwellDuration, + } + + return d.createAnomaly(&event, isSecurityMode) + } + + return nil +} + +func (d *Detector) createAnomaly(event *events.AnomalyEvent, isSecurityMode bool) *events.AnomalyEvent { + threshold := d.getAlertThreshold(isSecurityMode) + if event.Score < threshold { + return nil + } + + // Store in active anomalies + d.activeAnomalies[event.ID] = event + + // Persist to database + d.persistAnomaly(event) + + // Start alert chain + d.startAlertChain(event, isSecurityMode) + + // Fire callback + if d.onAnomaly != nil { + go d.onAnomaly(*event) + } + + log.Printf("[INFO] Anomaly detected: %s (score=%.2f, type=%s)", event.Description, event.Score, event.Type) + + return event +} + +func (d *Detector) getAlertThreshold(isSecurityMode bool) float64 { + if isSecurityMode { + return d.config.AlertThresholdSecurity + } + return d.config.AlertThresholdNormal +} + +func (d *Detector) recordOccupancySample(hourOfWeek int, zoneID string, personCount int, bleDevices []string, timestamp time.Time) { + devicesJSON, _ := jsonMarshal(bleDevices) + _, err := d.db.Exec(` + INSERT INTO occupancy_samples (hour_of_week, zone_id, person_count, ble_devices, timestamp) + VALUES (?, ?, ?, ?, ?) + `, hourOfWeek, zoneID, personCount, string(devicesJSON), timestamp.UnixNano()) + if err != nil { + log.Printf("[WARN] Failed to record occupancy sample: %v", err) + } +} + +func (d *Detector) recordDwellSample(hourOfWeek int, zoneID, personID string, dwellDuration time.Duration, timestamp time.Time) { + _, err := d.db.Exec(` + INSERT INTO dwell_samples (hour_of_week, zone_id, person_id, dwell_ns, timestamp) + VALUES (?, ?, ?, ?, ?) + `, hourOfWeek, zoneID, personID, dwellDuration.Nanoseconds(), timestamp.UnixNano()) + if err != nil { + log.Printf("[WARN] Failed to record dwell sample: %v", err) + } +} + +func (d *Detector) persistAnomaly(event *events.AnomalyEvent) { + _, err := d.db.Exec(` + INSERT INTO anomaly_events ( + id, type, score, description, timestamp, + zone_id, zone_name, blob_id, person_id, person_name, + device_mac, device_name, position_x, position_y, position_z, + hour_of_week, expected_occupancy, dwell_duration_ns, expected_dwell_ns, + rssi_dbm, seen_before + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, event.ID, event.Type, event.Score, event.Description, event.Timestamp.UnixNano(), + nullString(event.ZoneID), nullString(event.ZoneName), event.BlobID, + nullString(event.PersonID), nullString(event.PersonName), + nullString(event.DeviceMAC), nullString(event.DeviceName), + event.Position.X, event.Position.Y, event.Position.Z, + event.HourOfWeek, event.ExpectedOccupancy, + event.DwellDuration.Nanoseconds(), event.ExpectedDwell.Nanoseconds(), + event.RSSIdBm, event.SeenBefore) + if err != nil { + log.Printf("[WARN] Failed to persist anomaly: %v", err) + } +} + +func (d *Detector) startAlertChain(event *events.AnomalyEvent, isSecurityMode bool) { + state := &alertTimerState{ + anomalyID: event.ID, + } + + // T+0: Dashboard alarm (immediate - handled by UI via callback) + // Fire alert handler immediately for dashboard + if d.alertHandler != nil { + go d.alertHandler.SendAlert(*event, isSecurityMode) + } + + if isSecurityMode { + // Security mode: all alerts fire immediately + if d.alertHandler != nil { + d.alertHandler.SendWebhook(*event, true) + d.alertHandler.SendEscalation(*event) + } + event.AlertSent = true + event.WebhookSent = true + event.EscalationSent = true + now := time.Now() + event.AlertSentAt = now + event.WebhookSentAt = now + event.EscalationSentAt = now + d.updateAnomalyAlertState(event) + } else { + // Normal mode: staged alerts + // T+30s: notification + state.alertTimer = time.AfterFunc(30*time.Second, func() { + d.mu.Lock() + defer d.mu.Unlock() + if anomaly, exists := d.activeAnomalies[event.ID]; exists && !anomaly.Acknowledged { + if d.alertHandler != nil { + d.alertHandler.SendAlert(*anomaly, false) + } + anomaly.AlertSent = true + anomaly.AlertSentAt = time.Now() + d.updateAnomalyAlertState(anomaly) + } + }) + + // T+2min: webhook + state.webhookTimer = time.AfterFunc(2*time.Minute, func() { + d.mu.Lock() + defer d.mu.Unlock() + if anomaly, exists := d.activeAnomalies[event.ID]; exists && !anomaly.Acknowledged { + if d.alertHandler != nil { + d.alertHandler.SendWebhook(*anomaly, false) + } + anomaly.WebhookSent = true + anomaly.WebhookSentAt = time.Now() + d.updateAnomalyAlertState(anomaly) + } + }) + + // T+5min: escalation + state.escalationTimer = time.AfterFunc(5*time.Minute, func() { + d.mu.Lock() + defer d.mu.Unlock() + if anomaly, exists := d.activeAnomalies[event.ID]; exists && !anomaly.Acknowledged { + if d.alertHandler != nil { + d.alertHandler.SendEscalation(*anomaly) + } + anomaly.EscalationSent = true + anomaly.EscalationSentAt = time.Now() + d.updateAnomalyAlertState(anomaly) + } + }) + } + + d.pendingAlerts[event.ID] = state +} + +func (d *Detector) updateAnomalyAlertState(event *events.AnomalyEvent) { + d.db.Exec(` + UPDATE anomaly_events SET + alert_sent = ?, alert_sent_at = ?, + webhook_sent = ?, webhook_sent_at = ?, + escalation_sent = ?, escalation_sent_at = ? + WHERE id = ? + `, event.AlertSent, nullTime(event.AlertSentAt), + event.WebhookSent, nullTime(event.WebhookSentAt), + event.EscalationSent, nullTime(event.EscalationSentAt), + event.ID) +} + +// AcknowledgeAnomaly acknowledges an anomaly and cancels pending timers. +func (d *Detector) AcknowledgeAnomaly(anomalyID, feedback, acknowledgedBy string) error { + d.mu.Lock() + defer d.mu.Unlock() + + event, exists := d.activeAnomalies[anomalyID] + if !exists { + return fmt.Errorf("anomaly not found: %s", anomalyID) + } + + // Cancel pending timers + if state, exists := d.pendingAlerts[anomalyID]; exists { + if state.alertTimer != nil { + state.alertTimer.Stop() + } + if state.webhookTimer != nil { + state.webhookTimer.Stop() + } + if state.escalationTimer != nil { + state.escalationTimer.Stop() + } + delete(d.pendingAlerts, anomalyID) + } + + // Update event + event.Acknowledged = true + event.AcknowledgedAt = time.Now() + event.Feedback = feedback + event.AcknowledgedBy = acknowledgedBy + + // Update database + _, err := d.db.Exec(` + UPDATE anomaly_events SET + acknowledged = 1, + acknowledged_at = ?, + feedback = ? + WHERE id = ? + `, event.AcknowledgedAt.UnixNano(), feedback, anomalyID) + + if err != nil { + return err + } + + log.Printf("[INFO] Anomaly acknowledged: %s (feedback: %s)", anomalyID, feedback) + + return nil +} + +// UpdateBehaviourModel updates the behaviour model from collected samples. +// Should be called periodically (e.g., weekly). +func (d *Detector) UpdateBehaviourModel() error { + d.mu.Lock() + defer d.mu.Unlock() + + log.Printf("[INFO] Updating behaviour model from collected samples...") + + // Update behaviour slots from occupancy samples + rows, err := d.db.Query(` + SELECT hour_of_week, zone_id, + AVG(CASE WHEN person_count > 0 THEN 1.0 ELSE 0.0 END) as expected_occupancy, + AVG(person_count) as typical_person_count, + COUNT(*) as sample_count + FROM occupancy_samples + GROUP BY hour_of_week, zone_id + `) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + slot := &NormalBehaviourSlot{ + TypicalBLEDevices: make(map[string]float64), + } + if err := rows.Scan(&slot.HourOfWeek, &slot.ZoneID, &slot.ExpectedOccupancy, + &slot.TypicalPersonCount, &slot.SampleCount); err != nil { + continue + } + + // Calculate typical BLE devices (seen in > 50% of this slot) + bleRows, err := d.db.Query(` + SELECT ble_devices FROM occupancy_samples + WHERE hour_of_week = ? AND zone_id = ? + `, slot.HourOfWeek, slot.ZoneID) + if err == nil { + deviceCounts := make(map[string]int) + totalSamples := 0 + for bleRows.Next() { + var devicesJSON string + if err := bleRows.Scan(&devicesJSON); err != nil { + continue + } + var devices []string + if jsonUnmarshal(devicesJSON, &devices) == nil { + totalSamples++ + for _, mac := range devices { + deviceCounts[mac]++ + } + } + } + bleRows.Close() + + // Only include devices seen > 50% of the time + if totalSamples > 0 { + for mac, count := range deviceCounts { + frequency := float64(count) / float64(totalSamples) + if frequency > 0.5 { + slot.TypicalBLEDevices[mac] = frequency + } + } + } + } + + // Upsert to database + devicesJSON, _ := jsonMarshal(slot.TypicalBLEDevices) + d.db.Exec(` + INSERT INTO behaviour_slots (hour_of_week, zone_id, expected_occupancy, typical_person_count, sample_count, typical_ble_devices) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(hour_of_week, zone_id) DO UPDATE SET + expected_occupancy = excluded.expected_occupancy, + typical_person_count = excluded.typical_person_count, + sample_count = excluded.sample_count, + typical_ble_devices = excluded.typical_ble_devices + `, slot.HourOfWeek, slot.ZoneID, slot.ExpectedOccupancy, + slot.TypicalPersonCount, slot.SampleCount, string(devicesJSON)) + + key := fmt.Sprintf("%d-%s", slot.HourOfWeek, slot.ZoneID) + d.behaviourSlots[key] = slot + } + + // Update dwell slots + 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, + COUNT(*) as sample_count + FROM dwell_samples + GROUP BY hour_of_week, zone_id, person_id + `) + if err != nil { + return err + } + defer dwellRows.Close() + + for dwellRows.Next() { + slot := &DwellBehaviourSlot{} + var meanNS, stdNS int64 + if err := dwellRows.Scan(&slot.HourOfWeek, &slot.ZoneID, &slot.PersonID, + &meanNS, &stdNS, &slot.SampleCount); err != nil { + continue + } + slot.MeanDwellDuration = time.Duration(meanNS) + slot.StdDwellDuration = time.Duration(stdNS) + + d.db.Exec(` + INSERT INTO dwell_slots (hour_of_week, zone_id, person_id, mean_dwell_ns, std_dwell_ns, sample_count) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(hour_of_week, zone_id, person_id) DO UPDATE SET + mean_dwell_ns = excluded.mean_dwell_ns, + std_dwell_ns = excluded.std_dwell_ns, + sample_count = excluded.sample_count + `, slot.HourOfWeek, slot.ZoneID, slot.PersonID, + slot.MeanDwellDuration.Nanoseconds(), slot.StdDwellDuration.Nanoseconds(), slot.SampleCount) + + key := fmt.Sprintf("%d-%s-%s", slot.HourOfWeek, slot.ZoneID, slot.PersonID) + d.dwellSlots[key] = slot + } + + // Check if model should become ready + if !d.modelReady && time.Since(d.learningStartTime) >= 7*24*time.Hour { + d.modelReady = true + d.modelReadyAt = time.Now() + log.Printf("[INFO] Behaviour model is now ready after 7 days of learning") + } + + log.Printf("[INFO] Behaviour model updated: %d occupancy slots, %d dwell slots", + len(d.behaviourSlots), len(d.dwellSlots)) + + return nil +} + +// GetActiveAnomalies returns all unacknowledged anomalies. +func (d *Detector) GetActiveAnomalies() []*events.AnomalyEvent { + d.mu.RLock() + defer d.mu.RUnlock() + + result := make([]*events.AnomalyEvent, 0, len(d.activeAnomalies)) + for _, event := range d.activeAnomalies { + if !event.Acknowledged { + result = append(result, event) + } + } + return result +} + +// GetAnomalyHistory returns recent anomaly events. +func (d *Detector) GetAnomalyHistory(limit int) []*events.AnomalyEvent { + d.mu.RLock() + history := d.anomalyHistory + d.mu.RUnlock() + + if len(history) <= limit { + return history + } + return history[len(history)-limit:] +} + +// GetWeeklySummary returns a summary of anomalies for the past week. +func (d *Detector) GetWeeklySummary() events.WeeklyAnomalySummary { + d.mu.RLock() + defer d.mu.RUnlock() + + summary := events.WeeklyAnomalySummary{ + ByType: make(map[events.AnomalyType]int), + } + + oneWeekAgo := time.Now().Add(-7 * 24 * time.Hour) + + for _, event := range d.anomalyHistory { + if event.Timestamp.Before(oneWeekAgo) { + continue + } + + summary.TotalAnomalies++ + summary.ByType[event.Type]++ + + if event.Acknowledged { + switch event.Feedback { + case "expected": + summary.ExpectedEvents++ + case "intrusion": + summary.GenuineIntrusions++ + case "false_alarm": + summary.FalseAlarms++ + } + } else { + summary.Unacknowledged++ + } + } + + return summary +} + +// ClearAnomaly removes an anomaly from active state. +func (d *Detector) ClearAnomaly(anomalyID string) { + d.mu.Lock() + defer d.mu.Unlock() + + // Cancel timers + if state, exists := d.pendingAlerts[anomalyID]; exists { + if state.alertTimer != nil { + state.alertTimer.Stop() + } + if state.webhookTimer != nil { + state.webhookTimer.Stop() + } + if state.escalationTimer != nil { + state.escalationTimer.Stop() + } + delete(d.pendingAlerts, anomalyID) + } + + // Move to history + if event, exists := d.activeAnomalies[anomalyID]; exists { + d.anomalyHistory = append(d.anomalyHistory, event) + delete(d.activeAnomalies, anomalyID) + } +} + +// RunPeriodicUpdate starts a goroutine that updates the behaviour model periodically. +func (d *Detector) RunPeriodicUpdate(ctx context.Context, interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := d.UpdateBehaviourModel(); err != nil { + log.Printf("[WARN] Failed to update behaviour model: %v", err) + } + } + } + }() +} + +// getHourOfWeek returns the hour of the week (0-167) for a given time. +func getHourOfWeek(t time.Time) int { + weekday := int(t.Weekday()) + hour := t.Hour() + return weekday*24 + hour +} + +func nullString(s string) interface{} { + if s == "" { + return nil + } + return s +} + +func nullTime(t time.Time) interface{} { + if t.IsZero() { + return nil + } + 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") + } +} + +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") + } +} + +// Math helper +var _ = math.E // Use math package to avoid unused import error diff --git a/mothership/internal/analytics/flow.go b/mothership/internal/analytics/flow.go new file mode 100644 index 0000000..52e1628 --- /dev/null +++ b/mothership/internal/analytics/flow.go @@ -0,0 +1,814 @@ +// Package analytics provides crowd flow visualization and analysis. +package analytics + +import ( + "database/sql" + "math" + "sync" + "time" + + _ "modernc.org/sqlite" +) + +const ( + // GridCellSize is the size of each grid cell in metres (0.25m resolution) + GridCellSize = 0.25 + // MinMovementThreshold is the minimum movement (in metres) to record a trajectory segment + MinMovementThreshold = 0.2 + // StationarySpeedThreshold is the speed below which a track is considered stationary (m/s) + StationarySpeedThreshold = 0.1 + // DefaultRetentionDays is the default retention period for trajectory data + DefaultRetentionDays = 90 + // MinSegmentsForFlow is the minimum segments required to render a flow arrow + MinSegmentsForFlow = 5 + // MinDwellSamples is the minimum dwell samples required to render a hotspot + MinDwellSamples = 10 + // CorridorMinSegments is the minimum segments for a cell to be a corridor candidate + CorridorMinSegments = 10 + // CorridorMaxAngularVariance is the maximum angular variance for corridor classification + CorridorMaxAngularVariance = 0.3 +) + +// TrajectorySegment represents a single movement segment. +type TrajectorySegment struct { + ID string `json:"id"` + PersonID string `json:"person_id"` + FromX float64 `json:"from_x"` + FromZ float64 `json:"from_z"` // Ground plane (Y=0) + ToX float64 `json:"to_x"` + ToZ float64 `json:"to_z"` + Speed float64 `json:"speed"` + Timestamp time.Time `json:"timestamp"` +} + +// DwellAccumulatorKey identifies a dwell accumulator entry. +type DwellAccumulatorKey struct { + GridX int + GridZ int + PersonID string +} + +// DwellAccumulator represents accumulated dwell time at a location. +type DwellAccumulator struct { + GridX int `json:"grid_x"` + GridZ int `json:"grid_z"` + PersonID string `json:"person_id"` + Count int `json:"count"` + LastUpdated time.Time `json:"last_updated"` +} + +// DetectedCorridor represents a detected corridor region. +type DetectedCorridor struct { + ID string `json:"id"` + CentroidX float64 `json:"centroid_x"` + CentroidZ float64 `json:"centroid_z"` + DominantDirX float64 `json:"dominant_dir_x"` + DominantDirZ float64 `json:"dominant_dir_z"` + LengthM float64 `json:"length_m"` + WidthM float64 `json:"width_m"` + CellCount int `json:"cell_count"` + LastComputed time.Time `json:"last_computed"` +} + +// FlowCell represents aggregated flow data for a grid cell. +type FlowCell struct { + GridX int `json:"grid_x"` + GridZ int `json:"grid_z"` + VectorX float64 `json:"vector_x"` + VectorZ float64 `json:"vector_z"` + SegmentCount int `json:"segment_count"` +} + +// FlowMap is the computed flow map output. +type FlowMap struct { + Cells []FlowCell `json:"cells"` + GridSize float64 `json:"grid_size"` + ComputedAt time.Time `json:"computed_at"` +} + +// DwellHeatmapCell represents a cell in the dwell heatmap. +type DwellHeatmapCell struct { + GridX int `json:"grid_x"` + GridZ int `json:"grid_z"` + Count int `json:"count"` + Normalized float64 `json:"normalized"` +} + +// DwellHeatmap is the computed dwell heatmap output. +type DwellHeatmap struct { + Cells []DwellHeatmapCell `json:"cells"` + ComputedAt time.Time `json:"computed_at"` +} + +// TrackUpdate represents a track update from the tracker. +type TrackUpdate struct { + ID int + X, Y, Z float64 + VX, VY, VZ float64 + PersonID string +} + +// FlowAccumulator accumulates trajectory data for flow visualization. +type FlowAccumulator struct { + mu sync.RWMutex + db *sql.DB + dbPath string + retentionDays int + + // In-memory tracking of last waypoint per track + lastWaypoints map[int]*waypoint + + // Cache for computed flow map + flowCache *FlowMap + flowCacheTime time.Time + flowDirty bool + + // Cache for computed dwell heatmap + dwellCache *DwellHeatmap + dwellCacheTime time.Time + dwellDirty bool +} + +type waypoint struct { + x, z float64 + personID string +} + +// NewFlowAccumulator creates a new FlowAccumulator. +func NewFlowAccumulator(dbPath string) (*FlowAccumulator, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + + fa := &FlowAccumulator{ + db: db, + dbPath: dbPath, + retentionDays: DefaultRetentionDays, + lastWaypoints: make(map[int]*waypoint), + flowDirty: true, + dwellDirty: true, + } + + if err := fa.migrate(); err != nil { + db.Close() + return nil, err + } + + return fa, nil +} + +// Close closes the database connection. +func (fa *FlowAccumulator) Close() error { + return fa.db.Close() +} + +func (fa *FlowAccumulator) migrate() error { + _, err := fa.db.Exec(` + CREATE TABLE IF NOT EXISTS trajectory_segments ( + id TEXT PRIMARY KEY, + person_id TEXT NOT NULL DEFAULT '', + from_x REAL NOT NULL, + from_z REAL NOT NULL, + to_x REAL NOT NULL, + to_z REAL NOT NULL, + speed REAL NOT NULL, + timestamp INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_trajectory_timestamp ON trajectory_segments(timestamp); + CREATE INDEX IF NOT EXISTS idx_trajectory_person ON trajectory_segments(person_id); + CREATE INDEX IF NOT EXISTS idx_trajectory_timestamp_person ON trajectory_segments(timestamp, person_id); + + CREATE TABLE IF NOT EXISTS dwell_accumulator ( + grid_x INTEGER NOT NULL, + grid_z INTEGER NOT NULL, + person_id TEXT NOT NULL DEFAULT '', + count INTEGER NOT NULL DEFAULT 0, + last_updated INTEGER NOT NULL, + PRIMARY KEY (grid_x, grid_z, person_id) + ); + + CREATE TABLE IF NOT EXISTS detected_corridors ( + id TEXT PRIMARY KEY, + centroid_x REAL NOT NULL, + centroid_z REAL NOT NULL, + dominant_dir_x REAL NOT NULL, + dominant_dir_z REAL NOT NULL, + length_m REAL NOT NULL, + width_m REAL NOT NULL, + cell_count INTEGER NOT NULL, + last_computed INTEGER NOT NULL + ); + `) + return err +} + +// UpdateTrack processes a track update from the tracker. +// It records trajectory segments and dwell accumulator updates. +func (fa *FlowAccumulator) UpdateTrack(update TrackUpdate) { + fa.mu.Lock() + defer fa.mu.Unlock() + + now := time.Now() + speed := math.Sqrt(update.VX*update.VX + update.VZ*update.VZ) + + // Project to ground plane (ignore Y) + x, z := update.X, update.Z + + // Check if this is a stationary update for dwell accumulation + if speed < StationarySpeedThreshold { + gridX := int(math.Floor(x / GridCellSize)) + gridZ := int(math.Floor(z / GridCellSize)) + fa.recordDwell(gridX, gridZ, update.PersonID, now) + } + + // Check for trajectory segment + last, exists := fa.lastWaypoints[update.ID] + if exists { + dx := x - last.x + dz := z - last.z + dist := math.Sqrt(dx*dx + dz*dz) + + if dist >= MinMovementThreshold { + // Record trajectory segment + segID := generateSegmentID(update.ID, now) + fa.recordSegment(TrajectorySegment{ + ID: segID, + PersonID: last.personID, + FromX: last.x, + FromZ: last.z, + ToX: x, + ToZ: z, + Speed: speed, + Timestamp: now, + }) + + // Mark caches as dirty + fa.flowDirty = true + } + } + + // Update last waypoint + fa.lastWaypoints[update.ID] = &waypoint{ + x: x, + z: z, + personID: update.PersonID, + } +} + +// RemoveTrack removes a track's waypoint when it disappears. +func (fa *FlowAccumulator) RemoveTrack(trackID int) { + fa.mu.Lock() + delete(fa.lastWaypoints, trackID) + fa.mu.Unlock() +} + +func (fa *FlowAccumulator) recordSegment(seg TrajectorySegment) { + _, err := fa.db.Exec(` + INSERT INTO trajectory_segments (id, person_id, from_x, from_z, to_x, to_z, speed, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, seg.ID, seg.PersonID, seg.FromX, seg.FromZ, seg.ToX, seg.ToZ, seg.Speed, seg.Timestamp.UnixNano()) + if err != nil { + // Log but don't fail - we don't want to crash on DB errors + return + } +} + +func (fa *FlowAccumulator) recordDwell(gridX, gridZ int, personID string, now time.Time) { + _, err := fa.db.Exec(` + INSERT INTO dwell_accumulator (grid_x, grid_z, person_id, count, last_updated) + VALUES (?, ?, ?, 1, ?) + ON CONFLICT(grid_x, grid_z, person_id) DO UPDATE SET + count = count + 1, + last_updated = excluded.last_updated + `, gridX, gridZ, personID, now.UnixNano()) + if err != nil { + return + } + fa.dwellDirty = true +} + +// GetFlowMap computes and returns the flow map. +// Results are cached for 5 minutes or until data changes. +func (fa *FlowAccumulator) GetFlowMap(personID string, since, until time.Time) (*FlowMap, error) { + fa.mu.RLock() + defer fa.mu.RUnlock() + + // Check cache validity (5 minutes) + cacheDuration := 5 * time.Minute + now := time.Now() + + // If personID filter is set, bypass cache + if personID == "" && !fa.flowDirty && fa.flowCache != nil && now.Sub(fa.flowCacheTime) < cacheDuration { + return fa.flowCache, nil + } + + // Build query + query := ` + SELECT from_x, from_z, to_x, to_z + FROM trajectory_segments + WHERE timestamp >= ? AND timestamp <= ? + ` + args := []interface{}{since.UnixNano(), until.UnixNano()} + + if personID != "" { + query += " AND person_id = ?" + args = append(args, personID) + } + + rows, err := fa.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + // Accumulate flow vectors per cell + type cellAccumulator struct { + vectorX, vectorZ float64 + count int + } + cellMap := make(map[[2]int]*cellAccumulator) + + for rows.Next() { + var fromX, fromZ, toX, toZ float64 + if err := rows.Scan(&fromX, &fromZ, &toX, &toZ); err != nil { + continue + } + + // Use Bresenham's line algorithm to find cells the segment passes through + cells := bresenhamLine( + int(math.Floor(fromX/GridCellSize)), + int(math.Floor(fromZ/GridCellSize)), + int(math.Floor(toX/GridCellSize)), + int(math.Floor(toZ/GridCellSize)), + ) + + // Accumulate vector contribution for each cell + dx := toX - fromX + dz := toZ - fromZ + + for _, cell := range cells { + key := [2]int{cell[0], cell[1]} + acc, exists := cellMap[key] + if !exists { + acc = &cellAccumulator{} + cellMap[key] = acc + } + acc.vectorX += dx + acc.vectorZ += dz + acc.count++ + } + } + + // Build flow map + flowMap := &FlowMap{ + Cells: make([]FlowCell, 0, len(cellMap)), + GridSize: GridCellSize, + ComputedAt: now, + } + + for key, acc := range cellMap { + if acc.count < MinSegmentsForFlow { + continue + } + flowMap.Cells = append(flowMap.Cells, FlowCell{ + GridX: key[0], + GridZ: key[1], + VectorX: acc.vectorX / float64(acc.count), + VectorZ: acc.vectorZ / float64(acc.count), + SegmentCount: acc.count, + }) + } + + // Update cache only for unfiltered queries + if personID == "" { + fa.flowCache = flowMap + fa.flowCacheTime = now + fa.flowDirty = false + } + + return flowMap, nil +} + +// GetDwellHeatmap computes and returns the dwell heatmap. +// Results are cached for 5 minutes or until data changes. +func (fa *FlowAccumulator) GetDwellHeatmap(personID string) (*DwellHeatmap, error) { + fa.mu.RLock() + defer fa.mu.RUnlock() + + // Check cache validity (5 minutes) + cacheDuration := 5 * time.Minute + now := time.Now() + + // If personID filter is set, bypass cache + if personID == "" && !fa.dwellDirty && fa.dwellCache != nil && now.Sub(fa.dwellCacheTime) < cacheDuration { + return fa.dwellCache, nil + } + + // Build query + query := "SELECT grid_x, grid_z, count FROM dwell_accumulator" + args := []interface{}{} + + if personID != "" { + query += " WHERE person_id = ?" + args = append(args, personID) + } + + rows, err := fa.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var cells []DwellHeatmapCell + var maxCount int + + for rows.Next() { + var gridX, gridZ, count int + if err := rows.Scan(&gridX, &gridZ, &count); err != nil { + continue + } + if count < MinDwellSamples { + continue + } + cells = append(cells, DwellHeatmapCell{ + GridX: gridX, + GridZ: gridZ, + Count: count, + }) + if count > maxCount { + maxCount = count + } + } + + // Normalize to [0, 1] + heatmap := &DwellHeatmap{ + Cells: make([]DwellHeatmapCell, len(cells)), + ComputedAt: now, + } + + for i, cell := range cells { + heatmap.Cells[i] = DwellHeatmapCell{ + GridX: cell.GridX, + GridZ: cell.GridZ, + Count: cell.Count, + Normalized: float64(cell.Count) / float64(maxCount), + } + } + + // Update cache only for unfiltered queries + if personID == "" { + fa.dwellCache = heatmap + fa.dwellCacheTime = now + fa.dwellDirty = false + } + + return heatmap, nil +} + +// GetCorridors returns detected corridors. +func (fa *FlowAccumulator) GetCorridors() ([]DetectedCorridor, error) { + fa.mu.RLock() + defer fa.mu.RUnlock() + + rows, err := fa.db.Query(` + SELECT id, centroid_x, centroid_z, dominant_dir_x, dominant_dir_z, length_m, width_m, cell_count, last_computed + FROM detected_corridors + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var corridors []DetectedCorridor + for rows.Next() { + var c DetectedCorridor + var lastComputed int64 + if err := rows.Scan(&c.ID, &c.CentroidX, &c.CentroidZ, &c.DominantDirX, &c.DominantDirZ, + &c.LengthM, &c.WidthM, &c.CellCount, &lastComputed); err != nil { + continue + } + c.LastComputed = time.Unix(0, lastComputed) + corridors = append(corridors, c) + } + + return corridors, nil +} + +// ComputeCorridors recomputes corridor detection. +// Should be called periodically (e.g., weekly). +func (fa *FlowAccumulator) ComputeCorridors() error { + fa.mu.Lock() + defer fa.mu.Unlock() + + // Get all trajectory segments + rows, err := fa.db.Query(`SELECT from_x, from_z, to_x, to_z, timestamp FROM trajectory_segments`) + if err != nil { + return err + } + defer rows.Close() + + // Build per-cell angle lists for circular variance computation + type cellAngles struct { + angles []float64 + vectorsX []float64 + vectorsZ []float64 + } + cellMap := make(map[[2]int]*cellAngles) + + for rows.Next() { + var fromX, fromZ, toX, toZ float64 + var ts int64 + if err := rows.Scan(&fromX, &fromZ, &toX, &toZ, &ts); err != nil { + continue + } + + // Find cells the segment passes through + cells := bresenhamLine( + int(math.Floor(fromX/GridCellSize)), + int(math.Floor(fromZ/GridCellSize)), + int(math.Floor(toX/GridCellSize)), + int(math.Floor(toZ/GridCellSize)), + ) + + // Compute angle of this segment + angle := math.Atan2(toZ-fromZ, toX-fromX) + dx := toX - fromX + dz := toZ - fromZ + + for _, cell := range cells { + key := [2]int{cell[0], cell[1]} + acc, exists := cellMap[key] + if !exists { + acc = &cellAngles{} + cellMap[key] = acc + } + acc.angles = append(acc.angles, angle) + acc.vectorsX = append(acc.vectorsX, dx) + acc.vectorsZ = append(acc.vectorsZ, dz) + } + } + + // Identify corridor candidate cells + corridorCells := make(map[[2]int]bool) + for key, acc := range cellMap { + if len(acc.angles) < CorridorMinSegments { + continue + } + variance := circularVariance(acc.angles) + if variance < CorridorMaxAngularVariance { + corridorCells[key] = true + } + } + + // Connected component analysis + regions := findConnectedComponents(corridorCells) + + // Build corridor records + now := time.Now() + var corridors []DetectedCorridor + + for i, region := range regions { + if len(region) < 3 { + continue // Skip very small regions + } + + // Compute centroid + var sumX, sumZ float64 + for _, cell := range region { + sumX += float64(cell[0]) + sumZ += float64(cell[1]) + } + centroidX := (sumX / float64(len(region)) + 0.5) * GridCellSize + centroidZ := (sumZ / float64(len(region)) + 0.5) * GridCellSize + + // Compute dominant direction by averaging vectors + var avgVX, avgVZ float64 + var count int + for _, cell := range region { + if acc, exists := cellMap[cell]; exists { + for j := range acc.vectorsX { + avgVX += acc.vectorsX[j] + avgVZ += acc.vectorsZ[j] + count++ + } + } + } + if count > 0 { + avgVX /= float64(count) + avgVZ /= float64(count) + // Normalize + mag := math.Sqrt(avgVX*avgVX + avgVZ*avgVZ) + if mag > 0 { + avgVX /= mag + avgVZ /= mag + } + } + + // Compute bounding box for length/width + var minX, maxX, minZ, maxZ int + first := true + for _, cell := range region { + if first { + minX, maxX, minZ, maxZ = cell[0], cell[0], cell[1], cell[1] + first = false + } else { + if cell[0] < minX { minX = cell[0] } + if cell[0] > maxX { maxX = cell[0] } + if cell[1] < minZ { minZ = cell[1] } + if cell[1] > maxZ { maxZ = cell[1] } + } + } + + length := float64(maxZ-minZ+1) * GridCellSize + width := float64(maxX-minX+1) * GridCellSize + if width > length { + length, width = width, length + } + + corridors = append(corridors, DetectedCorridor{ + ID: generateCorridorID(i), + CentroidX: centroidX, + CentroidZ: centroidZ, + DominantDirX: avgVX, + DominantDirZ: avgVZ, + LengthM: length, + WidthM: width, + CellCount: len(region), + LastComputed: now, + }) + } + + // Clear existing corridors and insert new ones + tx, err := fa.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.Exec("DELETE FROM detected_corridors"); err != nil { + return err + } + + stmt, err := tx.Prepare(` + INSERT INTO detected_corridors (id, centroid_x, centroid_z, dominant_dir_x, dominant_dir_z, length_m, width_m, cell_count, last_computed) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + if err != nil { + return err + } + defer stmt.Close() + + for _, c := range corridors { + _, err := stmt.Exec(c.ID, c.CentroidX, c.CentroidZ, c.DominantDirX, c.DominantDirZ, + c.LengthM, c.WidthM, c.CellCount, c.LastComputed.UnixNano()) + if err != nil { + continue + } + } + + return tx.Commit() +} + +// PruneOldSegments removes trajectory segments older than retention period. +func (fa *FlowAccumulator) PruneOldSegments() error { + fa.mu.Lock() + defer fa.mu.Unlock() + + cutoff := time.Now().AddDate(0, 0, -fa.retentionDays) + _, err := fa.db.Exec(`DELETE FROM trajectory_segments WHERE timestamp < ?`, cutoff.UnixNano()) + if err == nil { + fa.flowDirty = true + } + return err +} + +// bresenhamLine returns all grid cells a line passes through. +func bresenhamLine(x0, z0, x1, z1 int) [][2]int { + var cells [][2]int + + dx := abs(x1 - x0) + dz := abs(z1 - z0) + sx := sign(x1 - x0) + sz := sign(z1 - z0) + + if dz <= dx { + err := 2 * dz - dx + for i := 0; i <= dx; i++ { + cells = append(cells, [2]int{x0, z0}) + if err > 0 { + z0 += sz + err -= 2 * dx + } + err += 2 * dz + x0 += sx + } + } else { + err := 2 * dx - dz + for i := 0; i <= dz; i++ { + cells = append(cells, [2]int{x0, z0}) + if err > 0 { + x0 += sx + err -= 2 * dz + } + err += 2 * dx + z0 += sz + } + } + + return cells +} + +// circularVariance computes the circular variance of angles. +// Returns a value in [0, 1] where 0 = all angles aligned, 1 = uniform distribution. +func circularVariance(angles []float64) float64 { + if len(angles) == 0 { + return 1.0 + } + + var sumSin, sumCos float64 + for _, a := range angles { + sumSin += math.Sin(a) + sumCos += math.Cos(a) + } + + n := float64(len(angles)) + meanLength := math.Sqrt(sumSin*sumSin+sumCos*sumCos) / n + + // Circular variance = 1 - R where R is mean resultant length + return 1.0 - meanLength +} + +// findConnectedComponents finds connected regions of cells using 4-connectivity. +func findConnectedComponents(cells map[[2]int]bool) [][][2]int { + if len(cells) == 0 { + return nil + } + + visited := make(map[[2]int]bool) + var regions [][][2]int + + for cell := range cells { + if visited[cell] { + continue + } + + // BFS to find connected component + var region [][2]int + queue := [][2]int{cell} + visited[cell] = true + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + region = append(region, current) + + // Check 4 neighbors + neighbors := [4][2]int{ + {current[0] - 1, current[1]}, + {current[0] + 1, current[1]}, + {current[0], current[1] - 1}, + {current[0], current[1] + 1}, + } + + for _, n := range neighbors { + if cells[n] && !visited[n] { + visited[n] = true + queue = append(queue, n) + } + } + } + + if len(region) > 0 { + regions = append(regions, region) + } + } + + return regions +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} + +func sign(x int) int { + if x < 0 { + return -1 + } + if x > 0 { + return 1 + } + return 0 +} + +func generateSegmentID(trackID int, t time.Time) string { + return string(rune(trackID)) + "_" + t.Format("20060102150405.000000000") +} + +func generateCorridorID(index int) string { + return "corridor_" + string(rune('A'+index%26)) + string(rune('0'+index/26)) +} diff --git a/mothership/internal/analytics/handler.go b/mothership/internal/analytics/handler.go new file mode 100644 index 0000000..d562131 --- /dev/null +++ b/mothership/internal/analytics/handler.go @@ -0,0 +1,113 @@ +package analytics + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi" +) + +// Handler provides REST API handlers for analytics. +type Handler struct { + accumulator *FlowAccumulator +} + +// NewHandler creates a new analytics handler. +func NewHandler(accumulator *FlowAccumulator) *Handler { + return &Handler{accumulator: accumulator} +} + +// RegisterRoutes registers analytics API routes on the given router. +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Get("/api/analytics/flow", h.handleGetFlow) + r.Get("/api/analytics/dwell", h.handleGetDwell) + r.Get("/api/analytics/corridors", h.handleGetCorridors) +} + +// handleGetFlow returns the flow map. +// Query params: +// - person_id: filter by person (optional) +// - since: Unix timestamp (optional, default 30 days ago) +// - until: Unix timestamp (optional, default now) +func (h *Handler) handleGetFlow(w http.ResponseWriter, r *http.Request) { + if h.accumulator == nil { + http.Error(w, "analytics not available", http.StatusServiceUnavailable) + return + } + + personID := r.URL.Query().Get("person_id") + + // Parse time range + var since, until time.Time + if sinceStr := r.URL.Query().Get("since"); sinceStr != "" { + if sinceUnix, err := strconv.ParseInt(sinceStr, 10, 64); err == nil { + since = time.Unix(sinceUnix, 0) + } + } + if untilStr := r.URL.Query().Get("until"); untilStr != "" { + if untilUnix, err := strconv.ParseInt(untilStr, 10, 64); err == nil { + until = time.Unix(untilUnix, 0) + } + } + + // Default time range: last 30 days + if since.IsZero() { + since = time.Now().AddDate(0, 0, -30) + } + if until.IsZero() { + until = time.Now() + } + + flowMap, err := h.accumulator.GetFlowMap(personID, since, until) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, flowMap) +} + +// handleGetDwell returns the dwell heatmap. +// Query params: +// - person_id: filter by person (optional) +func (h *Handler) handleGetDwell(w http.ResponseWriter, r *http.Request) { + if h.accumulator == nil { + http.Error(w, "analytics not available", http.StatusServiceUnavailable) + return + } + + personID := r.URL.Query().Get("person_id") + + heatmap, err := h.accumulator.GetDwellHeatmap(personID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, heatmap) +} + +// handleGetCorridors returns detected corridors. +func (h *Handler) handleGetCorridors(w http.ResponseWriter, r *http.Request) { + if h.accumulator == nil { + http.Error(w, "analytics not available", http.StatusServiceUnavailable) + return + } + + corridors, err := h.accumulator.GetCorridors() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, corridors) +} + +func writeJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +}