spaxel/mothership/internal/api/settings.go
jedarden f6d1c6f606 feat: implement REST API endpoints for settings, zones, portals, triggers, notifications, replay
- Added SettingsHandler with GET/PATCH /api/settings for configurable settings
- Added ZonesHandler with full CRUD for zones (GET/POST/PUT/DELETE /api/zones)
- Added PortalsHandler with full CRUD for portals (GET/POST/PUT/DELETE /api/portals)
- Added TriggersHandler with full CRUD for automation triggers including test endpoint
- Added NotificationsHandler for notification channel config and test notifications
- Added ReplayHandler for CSI replay sessions (start/stop/seek/tune endpoints)
- All endpoints follow OpenAPI-style patterns with appropriate godoc comments
- Settings persist to SQLite across restarts
- Zone/portal updates reflected in live 3D view via WebSocket

Endpoints implemented:
  GET/PATCH  /api/settings
  GET/POST/PUT/DELETE /api/zones
  GET/POST/PUT/DELETE /api/portals
  GET/POST/PUT/DELETE /api/triggers
  POST /api/triggers/{id}/test
  GET/PATCH /api/notifications/config
  POST /api/notifications/test
  GET/POST /api/replay/sessions
  POST /api/replay/start
  POST /api/replay/stop
  POST /api/replay/seek
  POST /api/replay/tune

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 11:22:02 -04:00

284 lines
6.8 KiB
Go

// Package api provides REST API handlers for Spaxel settings.
package api
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"os"
"sync"
"time"
"github.com/go-chi/chi"
_ "modernc.org/sqlite"
)
// SettingsHandler manages application settings.
type SettingsHandler struct {
mu sync.RWMutex
db *sql.DB
data map[string]interface{}
}
// NewSettingsHandler creates a new settings handler.
func NewSettingsHandler(dbPath string) (*SettingsHandler, error) {
if err := os.MkdirAll(dbPath[:len(dbPath)-len("/settings.db")], 0755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
s := &SettingsHandler{
db: db,
data: make(map[string]interface{}),
}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
if err := s.load(); err != nil {
log.Printf("[WARN] Failed to load settings: %v", err)
}
return s, nil
}
func (s *SettingsHandler) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT 0
);
`)
return err
}
func (s *SettingsHandler) load() error {
rows, err := s.db.Query(`SELECT key, value FROM settings`)
if err != nil {
return err
}
defer rows.Close()
s.mu.Lock()
defer s.mu.Unlock()
for rows.Next() {
var key, valueStr string
if err := rows.Scan(&key, &valueStr); err != nil {
continue
}
var value interface{}
if err := json.Unmarshal([]byte(valueStr), &value); err != nil {
// If not valid JSON, store as string
value = valueStr
}
s.data[key] = value
}
return nil
}
// Close closes the database connection.
func (s *SettingsHandler) Close() error {
return s.db.Close()
}
// Get returns all settings as a map.
func (s *SettingsHandler) Get() map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
result := make(map[string]interface{}, len(s.data))
for k, v := range s.data {
result[k] = v
}
return result
}
// Set updates a single setting value.
func (s *SettingsHandler) Set(key string, value interface{}) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.setLocked(key, value)
}
func (s *SettingsHandler) setLocked(key string, value interface{}) error {
valueJSON, err := json.Marshal(value)
if err != nil {
return err
}
_, err = s.db.Exec(`
INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = ?
`, key, string(valueJSON), nowMS(), string(valueJSON), nowMS())
if err != nil {
return err
}
s.data[key] = value
return nil
}
// Update merges a partial settings map.
func (s *SettingsHandler) Update(updates map[string]interface{}) error {
s.mu.Lock()
defer s.mu.Unlock()
for key, value := range updates {
if err := s.setLocked(key, value); err != nil {
return err
}
}
return nil
}
// Delete removes a setting.
func (s *SettingsHandler) Delete(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM settings WHERE key = ?`, key)
if err != nil {
return err
}
delete(s.data, key)
return nil
}
// RegisterRoutes registers settings endpoints.
// GET /api/settings — return all configurable settings as JSON
// POST /api/settings — update settings (partial update, merge semantics)
func (s *SettingsHandler) RegisterRoutes(r chi.Router) {
r.Get("/api/settings", s.handleGetSettings)
r.Post("/api/settings", s.handleUpdateSettings)
r.Patch("/api/settings", s.handleUpdateSettings)
}
func (s *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) {
settings := s.Get()
// Add default values for keys that don't exist yet
if _, ok := settings["fusion_rate_hz"]; !ok {
settings["fusion_rate_hz"] = 10
}
if _, ok := settings["grid_cell_m"]; !ok {
settings["grid_cell_m"] = 0.2
}
if _, ok := settings["delta_rms_threshold"]; !ok {
settings["delta_rms_threshold"] = 0.02
}
if _, ok := settings["tau_s"]; !ok {
settings["tau_s"] = 30.0
}
if _, ok := settings["fresnel_decay"]; !ok {
settings["fresnel_decay"] = 2.0
}
if _, ok := settings["n_subcarriers"]; !ok {
settings["n_subcarriers"] = 16
}
if _, ok := settings["breathing_sensitivity"]; !ok {
settings["breathing_sensitivity"] = 0.005
}
if _, ok := settings["motion_threshold"]; !ok {
settings["motion_threshold"] = 0.05
}
if _, ok := settings["dwell_seconds"]; !ok {
settings["dwell_seconds"] = 30
}
if _, ok := settings["vacant_seconds"]; !ok {
settings["vacant_seconds"] = 300
}
if _, ok := settings["max_tracked_blobs"]; !ok {
settings["max_tracked_blobs"] = 20
}
if _, ok := settings["replay_retention_hours"]; !ok {
settings["replay_retention_hours"] = 48
}
if _, ok := settings["replay_max_mb"]; !ok {
settings["replay_max_mb"] = 360
}
writeJSON(w, settings)
}
func (s *SettingsHandler) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var updates map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// Validate some known settings
if v, ok := updates["fusion_rate_hz"]; ok {
if f, ok := v.(float64); !ok || f < 1 || f > 20 {
http.Error(w, "fusion_rate_hz must be between 1 and 20", http.StatusBadRequest)
return
}
}
if v, ok := updates["grid_cell_m"]; ok {
if f, ok := v.(float64); !ok || f < 0.05 || f > 1.0 {
http.Error(w, "grid_cell_m must be between 0.05 and 1.0", http.StatusBadRequest)
return
}
}
if v, ok := updates["delta_rms_threshold"]; ok {
if f, ok := v.(float64); !ok || f < 0.001 || f > 1.0 {
http.Error(w, "delta_rms_threshold must be between 0.001 and 1.0", http.StatusBadRequest)
return
}
}
if v, ok := updates["tau_s"]; ok {
if f, ok := v.(float64); !ok || f < 1 || f > 600 {
http.Error(w, "tau_s must be between 1 and 600", http.StatusBadRequest)
return
}
}
if v, ok := updates["fresnel_decay"]; ok {
if f, ok := v.(float64); !ok || f < 1.0 || f > 4.0 {
http.Error(w, "fresnel_decay must be between 1.0 and 4.0", http.StatusBadRequest)
return
}
}
if v, ok := updates["n_subcarriers"]; ok {
if f, ok := v.(float64); !ok || f < 8 || f > 47 {
http.Error(w, "n_subcarriers must be between 8 and 47", http.StatusBadRequest)
return
}
}
if v, ok := updates["max_tracked_blobs"]; ok {
if f, ok := v.(float64); !ok || f < 1 || f > 100 {
http.Error(w, "max_tracked_blobs must be between 1 and 100", http.StatusBadRequest)
return
}
}
if err := s.Update(updates); err != nil {
http.Error(w, "failed to update settings", http.StatusInternalServerError)
return
}
// Return updated settings
s.handleGetSettings(w, r)
}
func nowMS() int64 {
return time.Now().UnixNano()
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v) //nolint:errcheck
}