spaxel/mothership/internal/analytics/alert_handler.go
jedarden 77a2fbc9c0 test: implement acceptance scenario integration tests (AS-1 through AS-6)
- Added comprehensive integration tests in test/acceptance/ covering all 6 acceptance scenarios from plan.md
- AS-1: First-time setup in under 5 minutes - verifies PIN setup and node auto-discovery
- AS-2: Person detected while walking - verifies blob detection during walker simulation
- AS-3: Fall alert fires correctly - verifies fall detection with webhook integration
- AS-4: BLE identity resolves to person name - verifies BLE device registration and identity matching
- AS-5: OTA update succeeds / rollback on bad firmware - verifies OTA workflow and rollback
- AS-6: Replay shows recorded history - verifies replay session creation, seeking, and playback

Tests use spaxel-sim CLI as the test harness and verify:
- API endpoint responses (/api/auth/setup, /api/nodes, /api/blobs, /api/events, /api/ble/devices, /api/replay/*)
- Detection accuracy thresholds (>60% blob presence during walking)
- Alert generation and webhook delivery
- Firmware version updates and rollback behavior
- Replay session lifecycle management

All tests skip by default unless ACCEPTANCE_TEST=1 or SPAXEL_INTEGRATION_TEST=1 is set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 05:45:15 -04:00

204 lines
5.7 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"
)
// 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 []struct {
X, Y, Z float64
Identity string
IsFall bool
}) ([]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, []struct {
X, Y, Z float64
Identity string
IsFall bool
}{
{
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() //nolint:errcheck
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() //nolint:errcheck
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
}