- 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>
204 lines
5.7 KiB
Go
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
|
|
}
|