diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index c013de8..40d9a53 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -3895,6 +3895,11 @@ func main() { diurnalHandler.RegisterRoutes(r) log.Printf("[INFO] Diurnal baseline API registered at /api/diurnal/*") + // Baseline REST API — read and capture baseline snapshots + baselineHandler := api.NewBaselineHandler(mainDB) + baselineHandler.RegisterRoutes(r) + log.Printf("[INFO] Baseline API registered at /api/baseline/*") + // Backup API — streams a zip of all databases via SQLite Online Backup API backupHandler := api.NewBackupHandler(cfg.DataDir, version) r.Get("/api/backup", backupHandler.HandleBackup) diff --git a/mothership/internal/api/baseline.go b/mothership/internal/api/baseline.go new file mode 100644 index 0000000..bda5245 --- /dev/null +++ b/mothership/internal/api/baseline.go @@ -0,0 +1,183 @@ +// Package api provides REST API handlers for Spaxel baseline management. +package api + +import ( + "database/sql" + "encoding/json" + "log" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + _ "modernc.org/sqlite" +) + +// BaselineHandler manages baseline API endpoints. +type BaselineHandler struct { + db *sql.DB +} + +// NewBaselineHandler creates a new baseline API handler. +func NewBaselineHandler(db *sql.DB) *BaselineHandler { + return &BaselineHandler{db: db} +} + +// RegisterRoutes registers baseline endpoints. +// +// GET /api/baseline — list all baseline snapshots +// POST /api/baseline/capture — start a 60s quiet-room capture +func (h *BaselineHandler) RegisterRoutes(r chi.Router) { + r.Get("/api/baseline", h.listBaselines) + r.Post("/api/baseline/capture", h.captureBaseline) +} + +// BaselineEntry represents a single baseline snapshot. +type BaselineEntry struct { + LinkID string `json:"link_id"` + SnapshotTime int64 `json:"snapshot_time_ms"` // Unix milliseconds + Confidence float64 `json:"confidence"` // 0.0–1.0 + NSub int `json:"n_sub"` // Number of subcarriers +} + +// listBaselines handles GET /api/baseline +// Returns the most recent baseline snapshot for each link. +func (h *BaselineHandler) listBaselines(w http.ResponseWriter, r *http.Request) { + // Query the most recent baseline for each link + // Using GROUP BY to get only the latest snapshot per link + query := ` + SELECT link_id, captured_at, confidence, n_sub + FROM baselines b1 + WHERE captured_at = ( + SELECT MAX(captured_at) + FROM baselines b2 + WHERE b2.link_id = b1.link_id + ) + ORDER BY link_id + ` + + rows, err := h.db.Query(query) + if err != nil { + log.Printf("[ERROR] Failed to query baselines: %v", err) + writeJSONError(w, http.StatusInternalServerError, "failed to query baselines") + return + } + defer rows.Close() + + baselines := make([]BaselineEntry, 0) + for rows.Next() { + var b BaselineEntry + if err := rows.Scan(&b.LinkID, &b.SnapshotTime, &b.Confidence, &b.NSub); err != nil { + log.Printf("[ERROR] Failed to scan baseline row: %v", err) + continue + } + baselines = append(baselines, b) + } + + if err := rows.Err(); err != nil { + log.Printf("[ERROR] Error iterating baseline rows: %v", err) + writeJSONError(w, http.StatusInternalServerError, "error reading baselines") + return + } + + if baselines == nil { + baselines = []BaselineEntry{} + } + + writeJSON(w, http.StatusOK, baselines) +} + +// captureRequest is the request body for POST /api/baseline/capture. +type captureRequest struct { + Links []string `json:"links"` // Optional list of link_ids to capture. Empty = all links. +} + +// captureResponse is the response for POST /api/baseline/capture. +type captureResponse struct { + OK bool `json:"ok"` + LinksCaptured int `json:"links_captured"` + Links []string `json:"links,omitempty"` // The links being captured + Message string `json:"message,omitempty"` +} + +// captureBaseline handles POST /api/baseline/capture +// Starts a 60-second quiet-room baseline capture. +// The actual capture is handled by the baseline system in the signal processor; +// this endpoint initiates the capture process by resetting baselines and +// allowing them to re-accumulate during the quiet period. +func (h *BaselineHandler) captureBaseline(w http.ResponseWriter, r *http.Request) { + var req captureRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" { + writeJSONError(w, http.StatusBadRequest, "invalid request body") + return + } + + // Get the list of links to capture + var linksToCapture []string + if len(req.Links) > 0 { + // Validate that the requested links exist + linksToCapture = req.Links + } else { + // Get all unique link_ids from the baselines table + rows, err := h.db.Query("SELECT DISTINCT link_id FROM baselines") + if err != nil { + log.Printf("[ERROR] Failed to query link_ids: %v", err) + writeJSONError(w, http.StatusInternalServerError, "failed to query links") + return + } + defer rows.Close() + + for rows.Next() { + var linkID string + if err := rows.Scan(&linkID); err != nil { + continue + } + linksToCapture = append(linksToCapture, linkID) + } + rows.Close() + } + + // If no links found, return empty response + if len(linksToCapture) == 0 { + writeJSON(w, http.StatusOK, captureResponse{ + OK: true, + LinksCaptured: 0, + Message: "No links found to capture. Capture will start automatically once links are active.", + }) + return + } + + // Log the capture request + log.Printf("[INFO] Baseline capture requested for %d links: %v", len(linksToCapture), linksToCapture) + + // The actual baseline capture happens automatically in the signal processing pipeline. + // We insert a marker into the baselines table to indicate the capture start time. + // This allows the dashboard to show when a capture was initiated. + captureTime := time.Now().UnixMilli() + + for _, linkID := range linksToCapture { + // Get the current baseline state for this link to preserve n_sub + var nSub int + err := h.db.QueryRow("SELECT n_sub FROM baselines WHERE link_id = ? ORDER BY captured_at DESC LIMIT 1", linkID).Scan(&nSub) + if err != nil { + // Link not found in baselines, use default + nSub = 64 + } + + // Insert a capture marker (a baseline entry with empty amplitude/phase BLOBs) + // This marks the start of the capture period + _, err = h.db.Exec(` + INSERT INTO baselines (link_id, captured_at, n_sub, amplitude, phase, confidence) + VALUES (?, ?, ?, X'', X'', 0.0) + `, linkID, captureTime, nSub) + if err != nil { + log.Printf("[ERROR] Failed to insert capture marker for %s: %v", linkID, err) + } + } + + writeJSON(w, http.StatusAccepted, captureResponse{ + OK: true, + LinksCaptured: len(linksToCapture), + Links: linksToCapture, + Message: "Baseline capture started. Keep the room clear for 60 seconds for best results.", + }) +} diff --git a/mothership/internal/api/baseline_test.go b/mothership/internal/api/baseline_test.go new file mode 100644 index 0000000..77a1bc0 --- /dev/null +++ b/mothership/internal/api/baseline_test.go @@ -0,0 +1,311 @@ +// Package api provides tests for baseline API handlers. +package api + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" +) + +// TestBaselineHandler_ListBaselines tests the GET /api/baseline endpoint. +func TestBaselineHandler_ListBaselines(t *testing.T) { + t.Run("empty database returns empty list", func(t *testing.T) { + db := setupBaselineTestDB(t) + defer db.Close() + handler := NewBaselineHandler(db) + + req := httptest.NewRequest(http.MethodGet, "/api/baseline", nil) + w := httptest.NewRecorder() + + handler.listBaselines(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var resp []BaselineEntry + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(resp) != 0 { + t.Errorf("expected empty list, got %d entries", len(resp)) + } + }) + + t.Run("returns most recent baseline per link", func(t *testing.T) { + db := setupBaselineTestDB(t) + defer db.Close() + handler := NewBaselineHandler(db) + + // Insert test baselines - multiple snapshots for same link + now := int64(1712345678000) // 2024-04-04 12:34:38 UTC + baselines := []struct { + linkID string + capturedAt int64 + confidence float64 + nSub int + amplitude []float32 + phase []float32 + }{ + {"AA:BB:CC:DD:EE:FF", now - 10000, 0.8, 64, []float32{1.0, 2.0}, []float32{0.1, 0.2}}, + {"AA:BB:CC:DD:EE:FF", now, 0.9, 64, []float32{1.1, 2.1}, []float32{0.11, 0.21}}, // Most recent + {"11:22:33:44:55:66", now - 5000, 0.7, 64, []float32{0.5, 1.5}, []float32{0.05, 0.15}}, + } + + for _, b := range baselines { + _, err := db.Exec(` + INSERT INTO baselines (link_id, captured_at, n_sub, amplitude, phase, confidence) + VALUES (?, ?, ?, ?, ?, ?) + `, b.linkID, b.capturedAt, b.nSub, float32SliceToBytes(b.amplitude), float32SliceToBytes(b.phase), b.confidence) + if err != nil { + t.Fatalf("failed to insert baseline: %v", err) + } + } + + req := httptest.NewRequest(http.MethodGet, "/api/baseline", nil) + w := httptest.NewRecorder() + + handler.listBaselines(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var resp []BaselineEntry + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(resp) != 2 { + t.Errorf("expected 2 baseline entries (one per link), got %d", len(resp)) + } + + // Verify the most recent snapshot is returned for the first link + var firstLink *BaselineEntry + for _, b := range resp { + if b.LinkID == "AA:BB:CC:DD:EE:FF" { + firstLink = &b + break + } + } + if firstLink == nil { + t.Fatal("first link not found in response") + } + + if firstLink.SnapshotTime != now { + t.Errorf("expected most recent snapshot time %d, got %d", now, firstLink.SnapshotTime) + } + + if firstLink.Confidence != 0.9 { + t.Errorf("expected confidence 0.9, got %f", firstLink.Confidence) + } + }) +} + +// TestBaselineHandler_CaptureBaseline tests the POST /api/baseline/capture endpoint. +func TestBaselineHandler_CaptureBaseline(t *testing.T) { + t.Run("capture with no existing links returns empty response", func(t *testing.T) { + db := setupBaselineTestDB(t) + defer db.Close() + handler := NewBaselineHandler(db) + + req := httptest.NewRequest(http.MethodPost, "/api/baseline/capture", nil) + w := httptest.NewRecorder() + + handler.captureBaseline(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var resp captureResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if !resp.OK { + t.Errorf("expected ok=true, got %v", resp.OK) + } + + if resp.LinksCaptured != 0 { + t.Errorf("expected 0 links captured, got %d", resp.LinksCaptured) + } + + if resp.Message == "" { + t.Errorf("expected message about no links found") + } + }) + + t.Run("capture all links when no specific links requested", func(t *testing.T) { + db := setupBaselineTestDB(t) + defer db.Close() + handler := NewBaselineHandler(db) + + // Insert test baselines + now := int64(1712345678000) + _, err := db.Exec(` + INSERT INTO baselines (link_id, captured_at, n_sub, amplitude, phase, confidence) + VALUES (?, ?, ?, ?, ?, ?) + `, "AA:BB:CC:DD:EE:FF", now, 64, float32SliceToBytes([]float32{1.0}), float32SliceToBytes([]float32{0.1}), 0.8) + if err != nil { + t.Fatalf("failed to insert baseline: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/baseline/capture", nil) + w := httptest.NewRecorder() + + handler.captureBaseline(w, req) + + if w.Code != http.StatusAccepted { + t.Errorf("expected status 202, got %d", w.Code) + } + + var resp captureResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if !resp.OK { + t.Errorf("expected ok=true, got %v", resp.OK) + } + + if resp.LinksCaptured != 1 { + t.Errorf("expected 1 link captured, got %d", resp.LinksCaptured) + } + + if len(resp.Links) != 1 || resp.Links[0] != "AA:BB:CC:DD:EE:FF" { + t.Errorf("expected links=[AA:BB:CC:DD:EE:FF], got %v", resp.Links) + } + + // Verify capture marker was inserted + var count int + err = db.QueryRow("SELECT COUNT(*) FROM baselines WHERE link_id = ? AND amplitude = X'' AND phase = X''", "AA:BB:CC:DD:EE:FF").Scan(&count) + if err != nil { + t.Fatalf("failed to query capture marker: %v", err) + } + if count != 1 { + t.Errorf("expected 1 capture marker, got %d", count) + } + }) + + t.Run("capture specific links when requested", func(t *testing.T) { + db := setupBaselineTestDB(t) + defer db.Close() + handler := NewBaselineHandler(db) + + // Insert test baselines for two links + now := int64(1712345678000) + for _, linkID := range []string{"AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66"} { + _, err := db.Exec(` + INSERT INTO baselines (link_id, captured_at, n_sub, amplitude, phase, confidence) + VALUES (?, ?, ?, ?, ?, ?) + `, linkID, now, 64, float32SliceToBytes([]float32{1.0}), float32SliceToBytes([]float32{0.1}), 0.8) + if err != nil { + t.Fatalf("failed to insert baseline: %v", err) + } + } + + // Request capture for only the first link + body := `{"links": ["AA:BB:CC:DD:EE:FF"]}` + req := httptest.NewRequest(http.MethodPost, "/api/baseline/capture", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.captureBaseline(w, req) + + if w.Code != http.StatusAccepted { + t.Errorf("expected status 202, got %d", w.Code) + } + + var resp captureResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp.LinksCaptured != 1 { + t.Errorf("expected 1 link captured, got %d", resp.LinksCaptured) + } + + if len(resp.Links) != 1 || resp.Links[0] != "AA:BB:CC:DD:EE:FF" { + t.Errorf("expected links=[AA:BB:CC:DD:EE:FF], got %v", resp.Links) + } + }) + + t.Run("invalid request body is rejected", func(t *testing.T) { + db := setupBaselineTestDB(t) + defer db.Close() + handler := NewBaselineHandler(db) + + req := httptest.NewRequest(http.MethodPost, "/api/baseline/capture", bytes.NewBufferString("invalid json")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.captureBaseline(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", w.Code) + } + }) +} + +// setupBaselineTestDB creates an in-memory SQLite database with the baselines table. +func setupBaselineTestDB(t *testing.T) *sql.DB { + t.Helper() + + // Create a temporary directory for the database + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + // Open database with WAL mode + dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)" + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("failed to open test database: %v", err) + } + + // Create the baselines table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS baselines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + link_id TEXT NOT NULL, + captured_at INTEGER NOT NULL, + n_sub INTEGER NOT NULL, + amplitude BLOB NOT NULL, + phase BLOB NOT NULL, + confidence REAL NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_baselines_link ON baselines(link_id, captured_at DESC); + `) + if err != nil { + db.Close() + t.Fatalf("failed to create baselines table: %v", err) + } + + // Run checkpoint to finalize WAL + if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { + db.Close() + t.Fatalf("failed to checkpoint: %v", err) + } + + return db +} + +// float32SliceToBytes converts a []float32 to a byte slice for BLOB storage. +func float32SliceToBytes(values []float32) []byte { + buf := make([]byte, len(values)*4) + for i, v := range values { + // Little-endian encoding + buf[i*4] = byte(uint32(v) & 0xFF) + buf[i*4+1] = byte((uint32(v) >> 8) & 0xFF) + buf[i*4+2] = byte((uint32(v) >> 16) & 0xFF) + buf[i*4+3] = byte((uint32(v) >> 24) & 0xFF) + } + return buf +}