feat: add diurnal baseline REST API endpoints

Add REST API for diurnal baseline data:
- GET /api/diurnal/status - learning status for all links
- GET /api/diurnal/slots/{linkID} - slot data for specific link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-09 08:53:01 -04:00
parent f6a5d560cb
commit 50856415b3
5 changed files with 346 additions and 5 deletions

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
3ff80c294d817bffc2c455069fbc592932c211ab
281bbefb570e6972fbc45cf641f02152e4123a6d

View file

@ -3154,6 +3154,11 @@ func main() {
tracksHandler.RegisterRoutes(r)
log.Printf("[INFO] Tracks API registered at /api/tracks")
// Diurnal baseline REST API
diurnalHandler := api.NewDiurnalHandler(pm)
diurnalHandler.RegisterRoutes(r)
log.Printf("[INFO] Diurnal baseline API registered at /api/diurnal/*")
// 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,103 @@
// Package api provides REST API handlers for diurnal baseline data.
package api
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/spaxel/mothership/internal/signal"
)
// DiurnalHandler manages the diurnal baseline API endpoints.
type DiurnalHandler struct {
pm DiurnalProcessorManager
}
// DiurnalProcessorManager defines the interface for accessing diurnal data from the signal processor.
type DiurnalProcessorManager interface {
GetDiurnalLearningStatus() []signal.DiurnalLearningStatus
GetProcessor(linkID string) DiurnalLinkProcessor
}
// DiurnalLinkProcessor defines the interface for accessing a single link's diurnal data.
type DiurnalLinkProcessor interface {
GetDiurnal() *signal.DiurnalBaseline
}
// NewDiurnalHandler creates a new diurnal API handler.
func NewDiurnalHandler(pm DiurnalProcessorManager) *DiurnalHandler {
return &DiurnalHandler{
pm: pm,
}
}
// RegisterRoutes registers diurnal endpoints.
func (h *DiurnalHandler) RegisterRoutes(r chi.Router) {
r.Get("/api/diurnal/status", h.getDiurnalStatus)
r.Get("/api/diurnal/slots/{linkID}", h.getDiurnalSlots)
}
// getDiurnalStatus handles GET /api/diurnal/status
// Returns the diurnal learning status for all links.
func (h *DiurnalHandler) getDiurnalStatus(w http.ResponseWriter, r *http.Request) {
statuses := h.pm.GetDiurnalLearningStatus()
writeJSON(w, http.StatusOK, statuses)
}
// getDiurnalSlots handles GET /api/diurnal/slots/{linkID}
// Returns the diurnal baseline slot data for a specific link.
func (h *DiurnalHandler) getDiurnalSlots(w http.ResponseWriter, r *http.Request) {
linkID := chi.URLParam(r, "linkID")
if linkID == "" {
writeJSONError(w, http.StatusBadRequest, "link_id is required")
return
}
processor := h.pm.GetProcessor(linkID)
if processor == nil {
writeJSONError(w, http.StatusNotFound, "link not found")
return
}
diurnal := processor.GetDiurnal()
if diurnal == nil {
writeJSONError(w, http.StatusNotFound, "diurnal baseline not found")
return
}
snapshot := diurnal.GetSnapshot()
if snapshot == nil {
writeJSONError(w, http.StatusInternalServerError, "failed to get diurnal snapshot")
return
}
// Build response with slot data
response := map[string]interface{}{
"link_id": snapshot.LinkID,
"created_at": snapshot.Created,
"current_hour": snapshot.Created.Hour(), // For consistency with signal package
"slot_amplitudes": snapshot.SlotValues,
"slot_confidences": diurnal.GetAllSlotConfidences(),
"slot_counts": snapshot.SlotCounts,
"is_learning": diurnal.IsLearning(),
"learning_progress": diurnal.GetLearningProgress(),
"is_ready": diurnal.IsReady(),
}
writeJSON(w, http.StatusOK, response)
}
// writeJSON writes a JSON response.
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
// writeJSONError writes a JSON error response.
func writeJSONError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}

View file

@ -0,0 +1,233 @@
// Package api provides tests for the diurnal baseline API.
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/spaxel/mothership/internal/signal"
)
// mockProcessorManager mocks the ProcessorManager interface for testing.
type mockDiurnalProcessorManager struct {
processors map[string]*mockDiurnalLinkProcessor
}
type mockDiurnalLinkProcessor struct {
diurnal *signal.DiurnalBaseline
}
func (m *mockDiurnalLinkProcessor) GetDiurnal() *signal.DiurnalBaseline {
return m.diurnal
}
func (m *mockDiurnalProcessorManager) GetDiurnalLearningStatus() []signal.DiurnalLearningStatus {
// For testing, we'll create mock status directly
return []signal.DiurnalLearningStatus{
{
LinkID: "AA:BB:CC:DD:EE:FF",
IsLearning: true,
DaysRemaining: 5.0,
Progress: 28.5,
IsReady: false,
SlotsReady: 8,
DiurnalConfidence: 0.33,
CreatedAt: time.Now().Add(-2 * 24 * time.Hour),
},
}
}
func (m *mockDiurnalProcessorManager) GetProcessor(linkID string) DiurnalLinkProcessor {
return m.processors[linkID]
}
// newMockDiurnalBaseline creates a mock diurnal baseline with test data.
func newMockDiurnalBaseline() *signal.DiurnalBaseline {
db := signal.NewDiurnalBaseline("AA:BB:CC:DD:EE:FF", 64)
// Simulate having some data by directly manipulating the slots
for i := 0; i < 24; i++ {
slot := db.GetSlot(i)
if slot != nil {
// Fill with test amplitude values
for k := 0; k < 64; k++ {
slot.Values[k] = 0.5 + float64(i)*0.01
}
slot.SampleCount = 300 + i*10 // Start with minimum required samples
slot.LastUpdate = time.Now().Add(-time.Duration(i) * time.Hour)
}
}
return db
}
// Test getDiurnalStatus
func TestGetDiurnalStatus(t *testing.T) {
handler := &DiurnalHandler{
pm: &mockDiurnalProcessorManager{},
}
req := httptest.NewRequest("GET", "/api/diurnal/status", nil)
w := httptest.NewRecorder()
handler.getDiurnalStatus(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var statuses []signal.DiurnalLearningStatus
if err := json.NewDecoder(w.Body).Decode(&statuses); err != nil {
t.Fatalf("decode: %v", err)
}
if len(statuses) != 1 {
t.Fatalf("got %d statuses, want 1", len(statuses))
}
status := statuses[0]
if status.LinkID != "AA:BB:CC:DD:EE:FF" {
t.Errorf("link_id = %q, want AA:BB:CC:DD:EE:FF", status.LinkID)
}
if !status.IsLearning {
t.Error("is_learning = false, want true")
}
if status.DaysRemaining != 5.0 {
t.Errorf("days_remaining = %f, want 5.0", status.DaysRemaining)
}
if status.Progress != 28.5 {
t.Errorf("progress = %f, want 28.5", status.Progress)
}
}
// Test getDiurnalSlots
func TestGetDiurnalSlots(t *testing.T) {
mockDiurnal := newMockDiurnalBaseline()
mockProc := &mockDiurnalLinkProcessor{diurnal: mockDiurnal}
handler := &DiurnalHandler{
pm: &mockDiurnalProcessorManager{
processors: map[string]*mockDiurnalLinkProcessor{
"AA:BB:CC:DD:EE:FF": mockProc,
},
},
}
req := httptest.NewRequest("GET", "/api/diurnal/slots/AA:BB:CC:DD:EE:FF", nil)
w := httptest.NewRecorder()
handler.getDiurnalSlots(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var response map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("decode: %v", err)
}
if response["link_id"] != "AA:BB:CC:DD:EE:FF" {
t.Errorf("link_id = %v, want AA:BB:CC:DD:EE:FF", response["link_id"])
}
// Check slot_amplitudes exists and has 24 slots
slotAmplitudes, ok := response["slot_amplitudes"].([24][]float64)
if !ok {
t.Fatal("slot_amplitudes missing or wrong type")
}
if len(slotAmplitudes) != 24 {
t.Errorf("got %d slots, want 24", len(slotAmplitudes))
}
// Check first slot has data
if len(slotAmplitudes[0]) != 64 {
t.Errorf("slot 0 has %d values, want 64", len(slotAmplitudes[0]))
}
// Check confidence values exist
slotConfidences, ok := response["slot_confidences"].([]float64)
if !ok {
t.Fatal("slot_confidences missing or wrong type")
}
if len(slotConfidences) != 24 {
t.Errorf("got %d confidences, want 24", len(slotConfidences))
}
// Check learning status
if response["is_learning"] != true {
t.Error("is_learning = false, want true")
}
if response["is_ready"] != false {
t.Error("is_ready = true, want false (only 2 days old)")
}
}
// Test getDiurnalSlots - missing linkID
func TestGetDiurnalSlots_MissingLinkID(t *testing.T) {
handler := &DiurnalHandler{
pm: &mockDiurnalProcessorManager{},
}
req := httptest.NewRequest("GET", "/api/diurnal/slots/", nil)
w := httptest.NewRecorder()
handler.getDiurnalSlots(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
var errResp map[string]string
json.NewDecoder(w.Body).Decode(&errResp)
if errResp["error"] == "" {
t.Error("expected error message")
}
}
// Test getDiurnalSlots - link not found
func TestGetDiurnalSlots_LinkNotFound(t *testing.T) {
handler := &DiurnalHandler{
pm: &mockDiurnalProcessorManager{
processors: map[string]*mockDiurnalLinkProcessor{},
},
}
req := httptest.NewRequest("GET", "/api/diurnal/slots/AA:BB:CC:DD:EE:FF", nil)
w := httptest.NewRecorder()
handler.getDiurnalSlots(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}
// Test getDiurnalSlots - nil diurnal
func TestGetDiurnalSlots_NilDiurnal(t *testing.T) {
mockProc := &mockDiurnalLinkProcessor{diurnal: nil}
handler := &DiurnalHandler{
pm: &mockDiurnalProcessorManager{
processors: map[string]*mockDiurnalLinkProcessor{
"AA:BB:CC:DD:EE:FF": mockProc,
},
},
}
req := httptest.NewRequest("GET", "/api/diurnal/slots/AA:BB:CC:DD:EE:FF", nil)
w := httptest.NewRecorder()
handler.getDiurnalSlots(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}