Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Ran gofmt across the entire mothership codebase to ensure consistent code formatting per Go standards. All tests pass after formatting.
532 lines
17 KiB
Go
532 lines
17 KiB
Go
// Package api provides REST API handlers for Spaxel notification channels.
|
|
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
_ "modernc.org/sqlite"
|
|
|
|
"github.com/spaxel/mothership/internal/render"
|
|
)
|
|
|
|
// NotificationsHandler manages notification delivery channels.
|
|
// Supported channel types: ntfy, pushover, gotify, webhook, mqtt.
|
|
type NotificationsHandler struct {
|
|
mu sync.RWMutex
|
|
db *sql.DB
|
|
channels map[string]*NotificationChannel
|
|
notifyService NotifySender
|
|
}
|
|
|
|
// NotificationChannel represents a notification delivery channel configuration.
|
|
//
|
|
// Channel types and their config schemas:
|
|
//
|
|
// ntfy: {"url":"https://ntfy.sh/my-topic", "token":"tk_..."} (token optional)
|
|
// pushover: {"app_token":"aXXXXXX...","user_key":"uXXXXXX..."}
|
|
// gotify: {"url":"https://gotify.example.com","token":"Aq7mXXXX"}
|
|
// webhook: {"url":"https://example.com/hook","method":"POST","headers":{"X-Secret":"abc"}}
|
|
// mqtt: {} (uses global MQTT connection; no config needed)
|
|
type NotificationChannel struct {
|
|
Type string `json:"type"` // ntfy, pushover, gotify, webhook, mqtt
|
|
Enabled bool `json:"enabled"` // true if channel is active
|
|
Config interface{} `json:"config,omitempty"` // channel-specific configuration
|
|
}
|
|
|
|
// NotifySender is the interface for sending test notifications.
|
|
type NotifySender interface {
|
|
Send(title, body string, data map[string]interface{}) error
|
|
}
|
|
|
|
// NewNotificationsHandler creates a new notifications handler.
|
|
func NewNotificationsHandler(dbPath string) (*NotificationsHandler, error) {
|
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
|
|
n := &NotificationsHandler{
|
|
db: db,
|
|
channels: make(map[string]*NotificationChannel),
|
|
}
|
|
|
|
if err := n.migrate(); err != nil {
|
|
db.Close() //nolint:errcheck
|
|
return nil, err
|
|
}
|
|
|
|
if err := n.load(); err != nil {
|
|
log.Printf("[WARN] Failed to load notification channels: %v", err)
|
|
}
|
|
|
|
return n, nil
|
|
}
|
|
|
|
func (n *NotificationsHandler) migrate() error {
|
|
_, err := n.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS notification_channels (
|
|
type TEXT PRIMARY KEY,
|
|
enabled INTEGER NOT NULL DEFAULT 0,
|
|
config_json TEXT NOT NULL DEFAULT '{}',
|
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
|
);
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func (n *NotificationsHandler) load() error {
|
|
rows, err := n.db.Query(`SELECT type, enabled, config_json FROM notification_channels`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close() //nolint:errcheck
|
|
|
|
for rows.Next() {
|
|
var nc NotificationChannel
|
|
var enabled int
|
|
var configJSON string
|
|
|
|
if err := rows.Scan(&nc.Type, &enabled, &configJSON); err != nil {
|
|
log.Printf("[WARN] Failed to scan notification channel: %v", err)
|
|
continue
|
|
}
|
|
|
|
nc.Enabled = enabled != 0
|
|
if err := json.Unmarshal([]byte(configJSON), &nc.Config); err != nil {
|
|
log.Printf("[WARN] Failed to unmarshal config for %s: %v", nc.Type, err)
|
|
// Keep as raw JSON string if unmarshaling fails
|
|
nc.Config = configJSON
|
|
}
|
|
|
|
n.channels[nc.Type] = &nc
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Close closes the database.
|
|
func (n *NotificationsHandler) Close() error {
|
|
return n.db.Close()
|
|
}
|
|
|
|
// SetNotifyService sets the notification sender for test notifications.
|
|
func (n *NotificationsHandler) SetNotifyService(ns NotifySender) {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
n.notifyService = ns
|
|
}
|
|
|
|
// GetChannels returns a copy of all notification channels.
|
|
func (n *NotificationsHandler) GetChannels() map[string]*NotificationChannel {
|
|
n.mu.RLock()
|
|
defer n.mu.RUnlock()
|
|
|
|
result := make(map[string]*NotificationChannel, len(n.channels))
|
|
for k, v := range n.channels {
|
|
result[k] = v
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetChannel returns a single channel by type.
|
|
func (n *NotificationsHandler) GetChannel(channelType string) (*NotificationChannel, bool) {
|
|
n.mu.RLock()
|
|
defer n.mu.RUnlock()
|
|
ch, ok := n.channels[channelType]
|
|
return ch, ok
|
|
}
|
|
|
|
// SetChannel updates or creates a notification channel.
|
|
func (n *NotificationsHandler) SetChannel(channelType string, enabled bool, config interface{}) error {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
|
|
// Validate config based on channel type
|
|
if err := validateChannelConfig(channelType, config); err != nil {
|
|
return err
|
|
}
|
|
|
|
configJSON, err := json.Marshal(config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
enabledInt := 0
|
|
if enabled {
|
|
enabledInt = 1
|
|
}
|
|
|
|
now := time.Now().UnixMilli()
|
|
_, err = n.db.Exec(`
|
|
INSERT INTO notification_channels (type, enabled, config_json, updated_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(type) DO UPDATE SET enabled = ?, config_json = ?, updated_at = ?
|
|
`, channelType, enabledInt, string(configJSON), now,
|
|
enabledInt, string(configJSON), now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
n.channels[channelType] = &NotificationChannel{
|
|
Type: channelType,
|
|
Enabled: enabled,
|
|
Config: config,
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateChannelConfig validates the configuration for a specific channel type.
|
|
func validateChannelConfig(channelType string, config interface{}) error {
|
|
if config == nil {
|
|
// Nil config is valid for mqtt, optional for others
|
|
return nil
|
|
}
|
|
|
|
configMap, ok := config.(map[string]interface{})
|
|
if !ok {
|
|
// Try to unmarshal as JSON
|
|
jsonBytes, err := json.Marshal(config)
|
|
if err != nil {
|
|
return &ChannelValidationError{Type: channelType, Reason: "config must be a JSON object"}
|
|
}
|
|
if err := json.Unmarshal(jsonBytes, &configMap); err != nil {
|
|
return &ChannelValidationError{Type: channelType, Reason: "config must be a JSON object"}
|
|
}
|
|
}
|
|
|
|
switch channelType {
|
|
case "ntfy":
|
|
// url is required, token is optional
|
|
if _, ok := configMap["url"]; !ok {
|
|
return &ChannelValidationError{Type: channelType, Field: "url", Reason: "required field missing"}
|
|
}
|
|
case "pushover":
|
|
// app_token and user_key are required
|
|
if _, ok := configMap["app_token"]; !ok {
|
|
return &ChannelValidationError{Type: channelType, Field: "app_token", Reason: "required field missing"}
|
|
}
|
|
if _, ok := configMap["user_key"]; !ok {
|
|
return &ChannelValidationError{Type: channelType, Field: "user_key", Reason: "required field missing"}
|
|
}
|
|
case "gotify":
|
|
// url and token are required
|
|
if _, ok := configMap["url"]; !ok {
|
|
return &ChannelValidationError{Type: channelType, Field: "url", Reason: "required field missing"}
|
|
}
|
|
if _, ok := configMap["token"]; !ok {
|
|
return &ChannelValidationError{Type: channelType, Field: "token", Reason: "required field missing"}
|
|
}
|
|
case "webhook":
|
|
// url is required, method and headers are optional
|
|
if _, ok := configMap["url"]; !ok {
|
|
return &ChannelValidationError{Type: channelType, Field: "url", Reason: "required field missing"}
|
|
}
|
|
// Validate method if provided
|
|
if method, ok := configMap["method"].(string); ok {
|
|
if method != "GET" && method != "POST" {
|
|
return &ChannelValidationError{Type: channelType, Field: "method", Reason: "must be GET or POST"}
|
|
}
|
|
}
|
|
case "mqtt":
|
|
// No config required for mqtt (uses global connection)
|
|
default:
|
|
return &ChannelValidationError{Type: channelType, Reason: "unknown channel type"}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ChannelValidationError represents a configuration validation error.
|
|
type ChannelValidationError struct {
|
|
Type string
|
|
Field string
|
|
Reason string
|
|
}
|
|
|
|
func (e *ChannelValidationError) Error() string {
|
|
if e.Field != "" {
|
|
return e.Type + "." + e.Field + ": " + e.Reason
|
|
}
|
|
return e.Type + ": " + e.Reason
|
|
}
|
|
|
|
// RegisterRoutes registers notification endpoints.
|
|
//
|
|
// Notification Channels Endpoints:
|
|
//
|
|
// The notification channels API manages delivery channels for alerts and notifications.
|
|
// Supported channel types: ntfy, pushover, gotify, webhook, mqtt.
|
|
//
|
|
// GET /api/notifications/config
|
|
//
|
|
// @Summary Get notification channel configuration
|
|
// @Description Returns all notification channel configurations including enabled status and channel-specific settings.
|
|
// @Tags notifications
|
|
// @Produce json
|
|
// @Success 200 {object} notificationConfigResponse "Channel configurations"
|
|
// @Router /api/notifications/config [get]
|
|
//
|
|
// POST /api/notifications/config
|
|
//
|
|
// @Summary Update notification channel configuration
|
|
// @Description Updates one or more notification channel configurations. Each channel has type-specific required fields.
|
|
// @Description <br>ntfy: requires "url", optional "token"<br>
|
|
// @Description pushover: requires "app_token", "user_key"<br>
|
|
// @Description gotify: requires "url", "token"<br>
|
|
// @Description webhook: requires "url", optional "method" (GET/POST), optional "headers"<br>
|
|
// @Description mqtt: no config required (uses global MQTT connection)
|
|
// @Tags notifications
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body setNotificationConfigRequest true "Channel configurations to update"
|
|
// @Success 200 {object} notificationConfigResponse "Updated channel configurations"
|
|
// @Failure 400 {object} map[string]string "Invalid request body or validation error"
|
|
// @Failure 500 {object} map[string]string "Failed to save configuration"
|
|
// @Router /api/notifications/config [post]
|
|
//
|
|
// POST /api/notifications/test
|
|
//
|
|
// @Summary Send a test notification
|
|
// @Description Sends a test notification via the specified channel type. The channel must be enabled.
|
|
// @Tags notifications
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body testNotificationRequest true "Test notification parameters"
|
|
// @Success 200 {object} map[string]interface{} "Test result"
|
|
// @Failure 400 {object} map[string]string "Invalid request or no enabled channel"
|
|
// @Failure 500 {object} map[string]string "Failed to send notification"
|
|
// @Router /api/notifications/test [post]
|
|
func (n *NotificationsHandler) RegisterRoutes(r chi.Router) {
|
|
r.Get("/api/notifications/config", n.handleGetConfig)
|
|
r.Post("/api/notifications/config", n.handleSetConfig)
|
|
r.Post("/api/notifications/test", n.handleSendTest)
|
|
r.Get("/api/notifications/preview", n.handlePreview)
|
|
}
|
|
|
|
// notificationConfigResponse is the response for channel configuration requests.
|
|
type notificationConfigResponse struct {
|
|
Channels map[string]*NotificationChannel `json:"channels"`
|
|
}
|
|
|
|
// handleGetConfig handles GET /api/notifications/config requests.
|
|
func (n *NotificationsHandler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, notificationConfigResponse{
|
|
Channels: n.GetChannels(),
|
|
})
|
|
}
|
|
|
|
// setNotificationConfigRequest is the request body for setting channel configuration.
|
|
type setNotificationConfigRequest struct {
|
|
Channels map[string]struct {
|
|
Type string `json:"type"`
|
|
Enabled bool `json:"enabled"`
|
|
Config interface{} `json:"config,omitempty"`
|
|
} `json:"channels"`
|
|
}
|
|
|
|
// handleSetConfig handles POST /api/notifications/config requests.
|
|
func (n *NotificationsHandler) handleSetConfig(w http.ResponseWriter, r *http.Request) {
|
|
var req setNotificationConfigRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Validate and update each channel
|
|
for channelType, ch := range req.Channels {
|
|
if ch.Type == "" {
|
|
ch.Type = channelType // Use map key as type if not specified
|
|
}
|
|
if ch.Type != channelType {
|
|
writeJSONError(w, http.StatusBadRequest, "channel type mismatch: key is "+channelType+" but body specifies "+ch.Type)
|
|
return
|
|
}
|
|
|
|
if err := n.SetChannel(ch.Type, ch.Enabled, ch.Config); err != nil {
|
|
if ce, ok := err.(*ChannelValidationError); ok {
|
|
writeJSONError(w, http.StatusBadRequest, ce.Error())
|
|
} else {
|
|
writeJSONError(w, http.StatusInternalServerError, "failed to save configuration: "+err.Error())
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
n.handleGetConfig(w, r)
|
|
}
|
|
|
|
// testNotificationRequest is the request body for sending a test notification.
|
|
type testNotificationRequest struct {
|
|
ChannelType string `json:"channel_type"` // ntfy, pushover, gotify, webhook, mqtt
|
|
Title string `json:"title"` // Custom title (optional)
|
|
Body string `json:"body"` // Custom body (optional)
|
|
Data map[string]interface{} `json:"data,omitempty"` // Additional data (optional)
|
|
}
|
|
|
|
// testNotificationResponse is the response for a test notification.
|
|
type testNotificationResponse struct {
|
|
Status string `json:"status"` // "sent" or "simulated"
|
|
Message string `json:"message"` // Human-readable result
|
|
}
|
|
|
|
// handleSendTest handles POST /api/notifications/test requests.
|
|
func (n *NotificationsHandler) handleSendTest(w http.ResponseWriter, r *http.Request) {
|
|
var req testNotificationRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Set defaults
|
|
if req.Title == "" {
|
|
req.Title = "Spaxel Test Notification"
|
|
}
|
|
if req.Body == "" {
|
|
req.Body = "This is a test notification from Spaxel."
|
|
}
|
|
if req.Data == nil {
|
|
req.Data = make(map[string]interface{})
|
|
}
|
|
req.Data["test"] = true
|
|
|
|
// Check if channel type exists and is enabled
|
|
ch, ok := n.GetChannel(req.ChannelType)
|
|
if !ok {
|
|
writeJSONError(w, http.StatusBadRequest, "unknown channel type: "+req.ChannelType)
|
|
return
|
|
}
|
|
if !ch.Enabled {
|
|
writeJSONError(w, http.StatusBadRequest, "channel is not enabled: "+req.ChannelType)
|
|
return
|
|
}
|
|
|
|
// Send test notification
|
|
n.mu.RLock()
|
|
sender := n.notifyService
|
|
n.mu.RUnlock()
|
|
|
|
if sender == nil {
|
|
writeJSON(w, http.StatusOK, testNotificationResponse{
|
|
Status: "simulated",
|
|
Message: "Test notification simulated (no sender attached)",
|
|
})
|
|
return
|
|
}
|
|
|
|
if err := sender.Send(req.Title, req.Body, req.Data); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "failed to send notification: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, testNotificationResponse{
|
|
Status: "sent",
|
|
Message: "Test notification sent successfully",
|
|
})
|
|
}
|
|
|
|
// handlePreview handles GET /api/notifications/preview
|
|
// Returns a rendered test image for UI development and QA.
|
|
// Query params:
|
|
// - type: notification type (fall, anomaly, zone_enter, sleep)
|
|
// - person: person name (optional, defaults to "Alice")
|
|
func (n *NotificationsHandler) handlePreview(w http.ResponseWriter, r *http.Request) {
|
|
// Parse query parameters
|
|
notifType := r.URL.Query().Get("type")
|
|
personName := r.URL.Query().Get("person")
|
|
|
|
// Set defaults
|
|
if personName == "" {
|
|
personName = "Alice"
|
|
}
|
|
|
|
// Define test zones
|
|
zones := []render.Zone{
|
|
{ID: "kitchen", Name: "Kitchen", X: 0, Y: 0, W: 4, D: 3, Color: "#4fc3f7"},
|
|
{ID: "living", Name: "Living", X: 4, Y: 0, W: 5, D: 4, Color: "#81c784"},
|
|
{ID: "hallway", Name: "Hallway", X: 4, Y: 4, W: 2, D: 2, Color: "#ffb74d"},
|
|
{ID: "bedroom", Name: "Bedroom", X: 6, Y: 4, W: 3, D: 3, Color: "#ba68c8"},
|
|
}
|
|
|
|
// Define test person
|
|
person := render.Person{
|
|
Name: personName,
|
|
X: 2.0,
|
|
Y: 1.5,
|
|
Z: 1.0,
|
|
Color: "#4488ff",
|
|
Confidence: 0.85,
|
|
IsFall: false,
|
|
}
|
|
|
|
var pngData []byte
|
|
var err error
|
|
|
|
// Generate thumbnail based on notification type
|
|
switch notifType {
|
|
case "fall":
|
|
pngData, err = render.GenerateFallDetectedThumbnail(10.0, 8.0, zones, person, "Kitchen")
|
|
case "anomaly":
|
|
pngData, err = render.GenerateAnomalyAlertThumbnail(10.0, 8.0, zones, "Living")
|
|
case "zone_enter":
|
|
pngData, err = render.GenerateZoneEnterThumbnail(10.0, 8.0, zones, person, "Kitchen")
|
|
case "sleep":
|
|
person.Z = 0.5 // Sleeping position
|
|
pngData, err = render.GenerateSleepSummaryThumbnail(10.0, 8.0, zones, person, "7h 23m")
|
|
default:
|
|
// Default to fall detection preview
|
|
pngData, err = render.GenerateFallDetectedThumbnail(10.0, 8.0, zones, person, "Kitchen")
|
|
}
|
|
|
|
if err != nil {
|
|
log.Printf("[ERROR] Failed to generate preview thumbnail: %v", err)
|
|
http.Error(w, "failed to generate preview", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Set headers and write PNG data
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
if _, err := w.Write(pngData); err != nil {
|
|
log.Printf("[ERROR] Failed to write preview response: %v", err)
|
|
}
|
|
}
|
|
|
|
// ── Notification sending (called by automation engine) ────────────────────────────
|
|
|
|
// SendNotification sends a notification via all enabled channels.
|
|
func (n *NotificationsHandler) SendNotification(title, body string, data map[string]interface{}) error {
|
|
n.mu.RLock()
|
|
sender := n.notifyService
|
|
channels := make([]NotificationChannel, 0, len(n.channels))
|
|
for _, ch := range n.channels {
|
|
if ch.Enabled {
|
|
channels = append(channels, *ch)
|
|
}
|
|
}
|
|
n.mu.RUnlock()
|
|
|
|
if len(channels) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if sender == nil {
|
|
log.Printf("[INFO] No notification sender attached, skipping: %s", title)
|
|
return nil
|
|
}
|
|
|
|
return sender.Send(title, body, data)
|
|
}
|