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.
530 lines
16 KiB
Go
530 lines
16 KiB
Go
// Package api provides REST API handlers for Spaxel settings.
|
|
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// SettingsHandler manages application settings.
|
|
// Settings are stored as key-value pairs in the settings table with JSON-encoded values.
|
|
type SettingsHandler struct {
|
|
mu sync.RWMutex
|
|
db *sql.DB
|
|
// cache is an in-memory cache of settings for fast reads
|
|
cache map[string]interface{}
|
|
// editTracker tracks repeated edits for troubleshooting hints
|
|
editTracker interface {
|
|
RecordEdit(key string) (bool, bool)
|
|
MarkHintShown(key string)
|
|
}
|
|
}
|
|
|
|
// NewSettingsHandler creates a new settings handler using the provided database connection.
|
|
// The database connection must be to the main spaxel.db which contains the settings table.
|
|
func NewSettingsHandler(db *sql.DB) *SettingsHandler {
|
|
s := &SettingsHandler{
|
|
db: db,
|
|
cache: make(map[string]interface{}),
|
|
}
|
|
// Load initial settings into cache
|
|
if err := s.load(); err != nil {
|
|
log.Printf("[WARN] Failed to load settings: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// SetEditTracker sets the edit tracker for monitoring repeated settings changes.
|
|
func (s *SettingsHandler) SetEditTracker(tracker interface {
|
|
RecordEdit(key string) (bool, bool)
|
|
MarkHintShown(key string)
|
|
}) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.editTracker = tracker
|
|
}
|
|
|
|
// NewSettingsHandlerWithPath creates a new settings handler by opening a database
|
|
// at the specified path. This is a convenience function for handlers that manage
|
|
// their own database connections.
|
|
func NewSettingsHandlerWithPath(dbPath string) (*SettingsHandler, error) {
|
|
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
|
|
// Verify the settings table exists
|
|
var tableName string
|
|
err = db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='settings'").Scan(&tableName)
|
|
if err != nil {
|
|
db.Close() //nolint:errcheck
|
|
return nil, err
|
|
}
|
|
|
|
s := &SettingsHandler{
|
|
db: db,
|
|
cache: make(map[string]interface{}),
|
|
}
|
|
// Load initial settings into cache
|
|
if err := s.load(); err != nil {
|
|
log.Printf("[WARN] Failed to load settings: %v", err)
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// Close closes the database connection.
|
|
func (s *SettingsHandler) Close() error {
|
|
return s.db.Close()
|
|
}
|
|
|
|
// load reads all settings from the database into the in-memory cache.
|
|
// It must be called with the mutex lock held.
|
|
func (s *SettingsHandler) load() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
rows, err := s.db.Query(`SELECT key, value_json FROM settings`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close() //nolint:errcheck
|
|
|
|
// Clear existing cache
|
|
s.cache = make(map[string]interface{})
|
|
|
|
for rows.Next() {
|
|
var key, valueJSON string
|
|
if err := rows.Scan(&key, &valueJSON); err != nil {
|
|
log.Printf("[WARN] Failed to scan setting key=%s: %v", key, err)
|
|
continue
|
|
}
|
|
|
|
var value interface{}
|
|
if err := json.Unmarshal([]byte(valueJSON), &value); err != nil {
|
|
// If not valid JSON, store as string
|
|
log.Printf("[WARN] Failed to unmarshal setting key=%s: %v", key, err)
|
|
s.cache[key] = valueJSON
|
|
} else {
|
|
s.cache[key] = value
|
|
}
|
|
}
|
|
|
|
return rows.Err()
|
|
}
|
|
|
|
// Get returns all settings as a map.
|
|
// Default values are included for keys that don't exist in the database.
|
|
func (s *SettingsHandler) Get() map[string]interface{} {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
result := make(map[string]interface{}, len(s.cache)+len(defaultSettings))
|
|
|
|
// First add cached values
|
|
for k, v := range s.cache {
|
|
result[k] = v
|
|
}
|
|
|
|
// Then add defaults for any missing keys
|
|
for k, v := range defaultSettings {
|
|
if _, exists := s.cache[k]; !exists {
|
|
result[k] = v
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// GetSingle returns a single setting value by key.
|
|
// Returns the value, true if found, or nil, false if not found.
|
|
func (s *SettingsHandler) GetSingle(key string) (interface{}, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if val, exists := s.cache[key]; exists {
|
|
return val, true
|
|
}
|
|
|
|
// Check defaults
|
|
if val, exists := defaultSettings[key]; exists {
|
|
return val, true
|
|
}
|
|
|
|
return nil, false
|
|
}
|
|
|
|
// Set updates a single setting value.
|
|
// The value is marshaled to JSON and stored in the database.
|
|
func (s *SettingsHandler) Set(key string, value interface{}) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return s.setLocked(key, value)
|
|
}
|
|
|
|
// setLocked updates a setting without acquiring the mutex.
|
|
// The caller must hold s.mu.
|
|
func (s *SettingsHandler) setLocked(key string, value interface{}) error {
|
|
valueJSON, err := json.Marshal(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now().UnixMilli()
|
|
_, err = s.db.Exec(`
|
|
INSERT INTO settings (key, value_json, updated_at) VALUES (?, ?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value_json = ?, updated_at = ?
|
|
`, key, string(valueJSON), now, string(valueJSON), now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.cache[key] = value
|
|
return nil
|
|
}
|
|
|
|
// Update merges a partial settings map with existing settings.
|
|
// Only the keys provided in the updates map are modified.
|
|
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 from both the database and cache.
|
|
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.cache, key)
|
|
return nil
|
|
}
|
|
|
|
// defaultSettings defines the default values for all known settings.
|
|
// These are returned when a key hasn't been set in the database.
|
|
var defaultSettings = map[string]interface{}{
|
|
"fusion_rate_hz": 10.0, // Fusion loop rate in Hz
|
|
"grid_cell_m": 0.2, // Fresnel grid cell size in meters
|
|
"delta_rms_threshold": 0.02, // Motion detection threshold
|
|
"tau_s": 30.0, // EMA baseline time constant in seconds
|
|
"fresnel_decay": 2.0, // Fresnel zone weight decay rate
|
|
"n_subcarriers": 16, // Number of subcarriers for NBVI selection
|
|
"breathing_sensitivity": 0.005, // Breathing detection threshold (radians RMS)
|
|
"motion_threshold": 0.05, // Smooth deltaRMS threshold for motion gating
|
|
"dwell_seconds": 30, // Default dwell trigger duration in seconds
|
|
"vacant_seconds": 300, // Default vacant trigger duration in seconds
|
|
"max_tracked_blobs": 20, // Maximum number of blobs to track simultaneously
|
|
"replay_retention_hours": 48, // CSI replay buffer retention in hours
|
|
"replay_max_mb": 360, // CSI replay buffer max size in MB
|
|
"security_mode": false, // Security mode enabled state
|
|
"security_mode_armed_at": nil, // Timestamp when security mode was armed
|
|
"events_archive_days": 90, // Events archive retention in days
|
|
"quiet_hours_start": "", // Quiet hours start time (HH:MM format)
|
|
"quiet_hours_end": "", // Quiet hours end time (HH:MM format)
|
|
// Auto-update settings
|
|
"auto_update_enabled": false, // Auto-update mode enabled
|
|
"quiet_window_start": "02:00", // Auto-update quiet window start (HH:MM)
|
|
"quiet_window_end": "05:00", // Auto-update quiet window end (HH:MM)
|
|
"canary_duration_min": 10, // Canary monitoring duration in minutes
|
|
"auto_update_quality_threshold": 0.05, // Quality degradation threshold (0-1)
|
|
}
|
|
|
|
// RegisterRoutes registers settings endpoints on the given router.
|
|
//
|
|
// Settings Endpoints:
|
|
//
|
|
// GET /api/settings — Return all configurable settings as JSON
|
|
//
|
|
// @Summary Get all settings
|
|
// @Description Returns all system settings as a JSON object. Default values are included
|
|
// @Description for any settings that haven't been explicitly set.
|
|
// @Tags settings
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]interface{} "Settings object"
|
|
// @Router /api/settings [get]
|
|
//
|
|
// POST /api/settings — Update settings (partial update, merge semantics)
|
|
//
|
|
// @Summary Update settings
|
|
// @Description Updates one or more settings. Only the keys provided in the request body
|
|
// @Description are modified; other settings remain unchanged.
|
|
// @Tags settings
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body map[string]interface{} true "Settings to update (partial)"
|
|
// @Success 200 {object} map[string]interface{} "Updated settings object"
|
|
// @Failure 400 {object} map[string]string "Invalid request body or validation error"
|
|
// @Failure 500 {object} map[string]string "Failed to update settings"
|
|
// @Router /api/settings [post]
|
|
//
|
|
// PATCH /api/settings — Update settings (alias for POST)
|
|
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)
|
|
}
|
|
|
|
// handleGetSettings handles GET /api/settings requests.
|
|
func (s *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
|
settings := s.Get()
|
|
writeJSON(w, http.StatusOK, settings)
|
|
}
|
|
|
|
// handleUpdateSettings handles POST/PATCH /api/settings requests.
|
|
// It validates known settings and applies partial updates with merge semantics.
|
|
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 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Validate known settings
|
|
if err := validateSettings(updates); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Track edits for troubleshooting hints
|
|
var hintPending bool
|
|
s.mu.RLock()
|
|
tracker := s.editTracker
|
|
s.mu.RUnlock()
|
|
|
|
if tracker != nil {
|
|
for key := range updates {
|
|
if pending, _ := tracker.RecordEdit(key); pending {
|
|
hintPending = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := s.Update(updates); err != nil {
|
|
log.Printf("[ERROR] Failed to update settings: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update settings"})
|
|
return
|
|
}
|
|
|
|
// Get updated settings
|
|
settings := s.Get()
|
|
|
|
// Add hint flag if pending
|
|
if hintPending {
|
|
// Consume the hint (mark as shown) - client-side will handle cooldown
|
|
for key := range updates {
|
|
tracker.MarkHintShown(key)
|
|
}
|
|
settings["repeated_edit_hint"] = true
|
|
}
|
|
|
|
// Return updated settings
|
|
writeJSON(w, http.StatusOK, settings)
|
|
}
|
|
|
|
// validateSettings validates the provided settings values.
|
|
// Returns an error if any setting value is outside its valid range.
|
|
func validateSettings(settings map[string]interface{}) error {
|
|
// Validate fusion_rate_hz: 1-20 Hz
|
|
if v, ok := settings["fusion_rate_hz"]; ok {
|
|
if f, ok := asFloat64(v); !ok || f < 1 || f > 20 {
|
|
return &ValidationError{Key: "fusion_rate_hz", Reason: "must be between 1 and 20"}
|
|
}
|
|
}
|
|
|
|
// Validate grid_cell_m: 0.05-1.0 meters
|
|
if v, ok := settings["grid_cell_m"]; ok {
|
|
if f, ok := asFloat64(v); !ok || f < 0.05 || f > 1.0 {
|
|
return &ValidationError{Key: "grid_cell_m", Reason: "must be between 0.05 and 1.0"}
|
|
}
|
|
}
|
|
|
|
// Validate delta_rms_threshold: 0.001-1.0
|
|
if v, ok := settings["delta_rms_threshold"]; ok {
|
|
if f, ok := asFloat64(v); !ok || f < 0.001 || f > 1.0 {
|
|
return &ValidationError{Key: "delta_rms_threshold", Reason: "must be between 0.001 and 1.0"}
|
|
}
|
|
}
|
|
|
|
// Validate tau_s: 1-600 seconds
|
|
if v, ok := settings["tau_s"]; ok {
|
|
if f, ok := asFloat64(v); !ok || f < 1 || f > 600 {
|
|
return &ValidationError{Key: "tau_s", Reason: "must be between 1 and 600"}
|
|
}
|
|
}
|
|
|
|
// Validate fresnel_decay: 1.0-4.0
|
|
if v, ok := settings["fresnel_decay"]; ok {
|
|
if f, ok := asFloat64(v); !ok || f < 1.0 || f > 4.0 {
|
|
return &ValidationError{Key: "fresnel_decay", Reason: "must be between 1.0 and 4.0"}
|
|
}
|
|
}
|
|
|
|
// Validate n_subcarriers: 8-47
|
|
if v, ok := settings["n_subcarriers"]; ok {
|
|
if f, ok := asFloat64(v); !ok || f < 8 || f > 47 {
|
|
return &ValidationError{Key: "n_subcarriers", Reason: "must be between 8 and 47"}
|
|
}
|
|
}
|
|
|
|
// Validate breathing_sensitivity: 0.001-0.1
|
|
if v, ok := settings["breathing_sensitivity"]; ok {
|
|
if f, ok := asFloat64(v); !ok || f < 0.001 || f > 0.1 {
|
|
return &ValidationError{Key: "breathing_sensitivity", Reason: "must be between 0.001 and 0.1"}
|
|
}
|
|
}
|
|
|
|
// Validate motion_threshold: 0.01-0.5
|
|
if v, ok := settings["motion_threshold"]; ok {
|
|
if f, ok := asFloat64(v); !ok || f < 0.01 || f > 0.5 {
|
|
return &ValidationError{Key: "motion_threshold", Reason: "must be between 0.01 and 0.5"}
|
|
}
|
|
}
|
|
|
|
// Validate dwell_seconds: 1-3600
|
|
if v, ok := settings["dwell_seconds"]; ok {
|
|
if f, ok := asFloat64(v); !ok || f < 1 || f > 3600 {
|
|
return &ValidationError{Key: "dwell_seconds", Reason: "must be between 1 and 3600"}
|
|
}
|
|
}
|
|
|
|
// Validate vacant_seconds: 10-7200
|
|
if v, ok := settings["vacant_seconds"]; ok {
|
|
if f, ok := asFloat64(v); !ok || f < 10 || f > 7200 {
|
|
return &ValidationError{Key: "vacant_seconds", Reason: "must be between 10 and 7200"}
|
|
}
|
|
}
|
|
|
|
// Validate max_tracked_blobs: 1-100
|
|
if v, ok := settings["max_tracked_blobs"]; ok {
|
|
if f, ok := asFloat64(v); !ok || f < 1 || f > 100 {
|
|
return &ValidationError{Key: "max_tracked_blobs", Reason: "must be between 1 and 100"}
|
|
}
|
|
}
|
|
|
|
// Validate replay_retention_hours: 1-168 (1 hour to 1 week)
|
|
if v, ok := settings["replay_retention_hours"]; ok {
|
|
if f, ok := asFloat64(v); !ok || f < 1 || f > 168 {
|
|
return &ValidationError{Key: "replay_retention_hours", Reason: "must be between 1 and 168"}
|
|
}
|
|
}
|
|
|
|
// Validate replay_max_mb: 10-10000
|
|
if v, ok := settings["replay_max_mb"]; ok {
|
|
if f, ok := asFloat64(v); !ok || f < 10 || f > 10000 {
|
|
return &ValidationError{Key: "replay_max_mb", Reason: "must be between 10 and 10000"}
|
|
}
|
|
}
|
|
|
|
// Validate security_mode: boolean
|
|
if v, ok := settings["security_mode"]; ok {
|
|
if _, ok := v.(bool); !ok {
|
|
return &ValidationError{Key: "security_mode", Reason: "must be a boolean"}
|
|
}
|
|
}
|
|
|
|
// Validate events_archive_days: 1-365
|
|
if v, ok := settings["events_archive_days"]; ok {
|
|
if f, ok := asFloat64(v); !ok || f < 1 || f > 365 {
|
|
return &ValidationError{Key: "events_archive_days", Reason: "must be between 1 and 365"}
|
|
}
|
|
}
|
|
|
|
// Validate quiet_window_start: HH:MM format
|
|
if v, ok := settings["quiet_window_start"]; ok {
|
|
if s, ok := v.(string); ok {
|
|
if s != "" {
|
|
if _, err := time.Parse("15:04", s); err != nil {
|
|
return &ValidationError{Key: "quiet_window_start", Reason: "must be in HH:MM format or empty"}
|
|
}
|
|
}
|
|
} else {
|
|
return &ValidationError{Key: "quiet_window_start", Reason: "must be a string in HH:MM format"}
|
|
}
|
|
}
|
|
|
|
// Validate quiet_window_end: HH:MM format
|
|
if v, ok := settings["quiet_window_end"]; ok {
|
|
if s, ok := v.(string); ok {
|
|
if s != "" {
|
|
if _, err := time.Parse("15:04", s); err != nil {
|
|
return &ValidationError{Key: "quiet_window_end", Reason: "must be in HH:MM format or empty"}
|
|
}
|
|
}
|
|
} else {
|
|
return &ValidationError{Key: "quiet_window_end", Reason: "must be a string in HH:MM format"}
|
|
}
|
|
}
|
|
|
|
// Validate auto_update_enabled: boolean
|
|
if v, ok := settings["auto_update_enabled"]; ok {
|
|
if _, ok := v.(bool); !ok {
|
|
return &ValidationError{Key: "auto_update_enabled", Reason: "must be a boolean"}
|
|
}
|
|
}
|
|
|
|
// Validate canary_duration_min: 5-60
|
|
if v, ok := settings["canary_duration_min"]; ok {
|
|
if f, ok := asFloat64(v); !ok || f < 5 || f > 60 {
|
|
return &ValidationError{Key: "canary_duration_min", Reason: "must be between 5 and 60"}
|
|
}
|
|
}
|
|
|
|
// Validate auto_update_quality_threshold: 0.01-0.5
|
|
if v, ok := settings["auto_update_quality_threshold"]; ok {
|
|
if f, ok := asFloat64(v); !ok || f < 0.01 || f > 0.5 {
|
|
return &ValidationError{Key: "auto_update_quality_threshold", Reason: "must be between 0.01 and 0.5"}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// asFloat64 attempts to convert a value to float64.
|
|
// JSON numbers are unmarshaled as float64, but integers may be unmarshaled
|
|
// differently depending on the JSON decoder.
|
|
func asFloat64(v interface{}) (float64, bool) {
|
|
switch val := v.(type) {
|
|
case float64:
|
|
return val, true
|
|
case float32:
|
|
return float64(val), true
|
|
case int:
|
|
return float64(val), true
|
|
case int64:
|
|
return float64(val), true
|
|
case int32:
|
|
return float64(val), true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
// ValidationError represents a validation error for a specific setting.
|
|
type ValidationError struct {
|
|
Key string
|
|
Reason string
|
|
}
|
|
|
|
func (e *ValidationError) Error() string {
|
|
return e.Key + ": " + e.Reason
|
|
}
|