Added tests for: - Auto-away activation when all registered BLE devices absent for 15+ minutes - Auto-disarm triggering when registered BLE device returns with strong RSSI - Manual override pausing auto-away detection - System mode GET/PUT REST API endpoints - Edge cases: no registered devices, weak BLE signals, unregistered devices Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
197 lines
5.6 KiB
Go
197 lines
5.6 KiB
Go
// Package analytics provides alert handling for anomaly detection using the notification service.
|
|
package analytics
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/spaxel/mothership/internal/events"
|
|
"github.com/spaxel/mothership/internal/notify"
|
|
)
|
|
|
|
// NotificationAlertHandler implements AlertHandler using a notification service.
|
|
type NotificationAlertHandler struct {
|
|
notifyService NotificationService
|
|
httpClient *http.Client
|
|
webhookURL string
|
|
escalationURL string
|
|
}
|
|
|
|
// NotificationService is the interface needed from the notify package.
|
|
type NotificationService interface {
|
|
Send(notif Notification) error
|
|
GenerateFloorPlanThumbnail(width, height int, blobs []notify.FloorPlanBlob) ([]byte, error)
|
|
}
|
|
|
|
// Notification represents a notification to send.
|
|
type Notification struct {
|
|
Title string
|
|
Body string
|
|
Priority int
|
|
Tags []string
|
|
Image []byte
|
|
ImageType string
|
|
Data map[string]interface{}
|
|
Timestamp time.Time
|
|
}
|
|
|
|
// NewNotificationAlertHandler creates a new alert handler backed by the notification service.
|
|
func NewNotificationAlertHandler(notifyService NotificationService) *NotificationAlertHandler {
|
|
return &NotificationAlertHandler{
|
|
notifyService: notifyService,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
}
|
|
|
|
// SetWebhookURL sets the webhook URL for anomaly alerts.
|
|
func (h *NotificationAlertHandler) SetWebhookURL(url string) {
|
|
h.webhookURL = url
|
|
}
|
|
|
|
// SetEscalationURL sets the escalation webhook URL.
|
|
func (h *NotificationAlertHandler) SetEscalationURL(url string) {
|
|
h.escalationURL = url
|
|
}
|
|
|
|
// SendAlert sends an alert notification.
|
|
func (h *NotificationAlertHandler) SendAlert(event events.AnomalyEvent, immediate bool) error {
|
|
// Generate floor plan thumbnail
|
|
thumbnail, err := h.notifyService.GenerateFloorPlanThumbnail(400, 300, []notify.FloorPlanBlob{
|
|
{
|
|
X: event.Position.X,
|
|
Y: event.Position.Y,
|
|
Z: event.Position.Z,
|
|
Identity: event.PersonName,
|
|
IsFall: false,
|
|
},
|
|
})
|
|
if err != nil {
|
|
log.Printf("[WARN] Failed to generate thumbnail for alert: %v", err)
|
|
}
|
|
|
|
notif := Notification{
|
|
Title: "Anomaly Detected",
|
|
Body: event.Description,
|
|
Priority: 2, // High priority for anomalies
|
|
Tags: []string{"spaxel", "anomaly"},
|
|
Image: thumbnail,
|
|
ImageType: "image/png",
|
|
Data: map[string]interface{}{
|
|
"anomaly_id": event.ID,
|
|
"anomaly_type": string(event.Type),
|
|
"zone_id": event.ZoneID,
|
|
"zone_name": event.ZoneName,
|
|
"person_id": event.PersonID,
|
|
"person_name": event.PersonName,
|
|
"timestamp": event.Timestamp.Format(time.RFC3339),
|
|
"immediate": immediate,
|
|
},
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
// Set higher priority for security mode
|
|
if immediate {
|
|
notif.Priority = 4 // Emergency priority
|
|
notif.Tags = append(notif.Tags, "security")
|
|
}
|
|
|
|
return h.notifyService.Send(notif)
|
|
}
|
|
|
|
// SendWebhook sends a webhook notification.
|
|
func (h *NotificationAlertHandler) SendWebhook(event events.AnomalyEvent, immediate bool) error {
|
|
if h.webhookURL == "" {
|
|
return nil // No webhook configured
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"anomaly_id": event.ID,
|
|
"type": string(event.Type),
|
|
"score": event.Score,
|
|
"description": event.Description,
|
|
"timestamp": event.Timestamp.Format(time.RFC3339),
|
|
"zone_id": event.ZoneID,
|
|
"zone_name": event.ZoneName,
|
|
"person_id": event.PersonID,
|
|
"person_name": event.PersonName,
|
|
"device_mac": event.DeviceMAC,
|
|
"position": event.Position,
|
|
"hour_of_week": event.HourOfWeek,
|
|
"expected_occupancy": event.ExpectedOccupancy,
|
|
"immediate": immediate,
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal webhook payload: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", h.webhookURL, bytes.NewBuffer(body))
|
|
if err != nil {
|
|
return fmt.Errorf("create webhook request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", "Spaxel/1.0")
|
|
|
|
resp, err := h.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("send webhook: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
log.Printf("[INFO] Anomaly webhook sent: %s (status: %d)", event.ID, resp.StatusCode)
|
|
return nil
|
|
}
|
|
|
|
// SendEscalation sends an escalation webhook notification.
|
|
func (h *NotificationAlertHandler) SendEscalation(event events.AnomalyEvent) error {
|
|
if h.escalationURL == "" {
|
|
return nil // No escalation webhook configured
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"anomaly_id": event.ID,
|
|
"type": string(event.Type),
|
|
"score": event.Score,
|
|
"description": event.Description,
|
|
"timestamp": event.Timestamp.Format(time.RFC3339),
|
|
"zone_id": event.ZoneID,
|
|
"zone_name": event.ZoneName,
|
|
"person_id": event.PersonID,
|
|
"person_name": event.PersonName,
|
|
"escalation": true,
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal escalation payload: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", h.escalationURL, bytes.NewBuffer(body))
|
|
if err != nil {
|
|
return fmt.Errorf("create escalation request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", "Spaxel/1.0")
|
|
|
|
resp, err := h.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("send escalation: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return fmt.Errorf("escalation returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
log.Printf("[INFO] Anomaly escalation sent: %s (status: %d)", event.ID, resp.StatusCode)
|
|
return nil
|
|
}
|