api: add GET /api/baseline and POST /api/baseline/capture endpoints
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run

Implements baseline read/capture endpoints for the dashboard. GET /api/baseline
returns [{link_id, snapshot_time_ms, confidence, n_sub}] for all links.
POST /api/baseline/capture starts a 60s quiet-room capture with optional
links filter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-05 13:50:11 -04:00
parent b067131614
commit 21829b9738
3 changed files with 499 additions and 0 deletions

View file

@ -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)

View file

@ -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.01.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.",
})
}

View file

@ -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
}