spaxel/mothership/internal/sleep/handler.go
jedarden b1c2218146 feat: wire anomaly detection & security mode API endpoints
AnomalyDetector initialized in main() with periodic model updates.
Anomaly events broadcast to dashboard WS as 'alert' messages via
BroadcastAlert. GET /api/anomalies?since=24h lists recent events.
POST /api/security/arm and /api/security/disarm manage security mode.
GET /api/security/status returns armed state, learning progress, and
24h anomaly count. Arm/disarm state persisted to learning_state table
and restored on restart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 15:09:34 -04:00

381 lines
11 KiB
Go

// Package sleep provides REST API handlers for sleep quality monitoring.
package sleep
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
_ "modernc.org/sqlite"
)
// Handler provides REST API handlers for the sleep module.
type Handler struct {
monitor *Monitor
records *SleepRecordStore
}
// NewHandler creates a new sleep handler.
func NewHandler(monitor *Monitor) *Handler {
return &Handler{
monitor: monitor,
}
}
// SetDB sets the main DB connection for sleep record persistence.
func (h *Handler) SetDB(dbPath string) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
log.Printf("[WARN] Sleep records: failed to open DB %s: %v", dbPath, err)
return
}
db.SetMaxOpenConns(1)
h.records = NewSleepRecordStore(db)
}
// RegisterRoutes registers the sleep API routes on the provided router.
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/api/sleep", h.handleGetSleepRecords)
r.Get("/api/sleep/summary", h.handleGetSleepSummary)
r.Get("/api/sleep/status", h.handleGetStatus)
r.Get("/api/sleep/reports", h.handleGetReports)
r.Get("/api/sleep/reports/{linkID}", h.handleGetReport)
r.Post("/api/sleep/reports/generate", h.handleGenerateReports)
r.Get("/api/sleep/sessions", h.handleGetSessions)
r.Get("/api/sleep/sessions/{linkID}", h.handleGetSession)
r.Get("/api/sleep/sessions/{linkID}/samples", h.handleGetSamples)
}
// handleGetStatus returns the current sleep monitoring status.
func (h *Handler) handleGetStatus(w http.ResponseWriter, r *http.Request) {
if h.monitor == nil {
http.Error(w, "sleep monitor not available", http.StatusServiceUnavailable)
return
}
status := h.monitor.GetStatus()
writeJSON(w, status)
}
// handleGetReports returns all available sleep reports.
func (h *Handler) handleGetReports(w http.ResponseWriter, r *http.Request) {
if h.monitor == nil {
http.Error(w, "sleep monitor not available", http.StatusServiceUnavailable)
return
}
reports := h.monitor.ForceReportGeneration()
// Convert to JSON-serializable format
result := make(map[string]interface{})
for linkID, report := range reports {
result[linkID] = report.ToJSONMap()
}
writeJSON(w, result)
}
// handleGetReport returns the sleep report for a specific link.
func (h *Handler) handleGetReport(w http.ResponseWriter, r *http.Request) {
if h.monitor == nil {
http.Error(w, "sleep monitor not available", http.StatusServiceUnavailable)
return
}
linkID := chi.URLParam(r, "linkID")
if linkID == "" {
http.Error(w, "link_id required", http.StatusBadRequest)
return
}
report := h.monitor.GetSleepReport(linkID)
if report == nil {
http.Error(w, "no sleep report available for this link", http.StatusNotFound)
return
}
writeJSON(w, report.ToJSONMap())
}
// handleGenerateReports forces generation of morning reports.
func (h *Handler) handleGenerateReports(w http.ResponseWriter, r *http.Request) {
if h.monitor == nil {
http.Error(w, "sleep monitor not available", http.StatusServiceUnavailable)
return
}
reports := h.monitor.ForceReportGeneration()
// Return summary of generated reports
summaries := make([]map[string]interface{}, 0, len(reports))
for linkID, report := range reports {
summaries = append(summaries, map[string]interface{}{
"link_id": linkID,
"overall_score": report.Metrics.OverallScore,
"quality_rating": report.Metrics.QualityRating,
"generated_at": report.GeneratedAt.Unix(),
})
}
writeJSON(w, map[string]interface{}{
"generated": len(reports),
"reports": summaries,
"timestamp": time.Now().Unix(),
})
}
// handleGetSessions returns all active sleep sessions.
func (h *Handler) handleGetSessions(w http.ResponseWriter, r *http.Request) {
if h.monitor == nil {
http.Error(w, "sleep monitor not available", http.StatusServiceUnavailable)
return
}
sessions := h.monitor.GetAllSessions()
// Convert sessions to summary format
result := make([]map[string]interface{}, 0, len(sessions))
for linkID, session := range sessions {
session.mu.RLock()
summary := map[string]interface{}{
"link_id": linkID,
"current_state": session.GetCurrentState().String(),
"is_active": session.isActive,
"breathing_samples": len(session.breathingSamples),
"motion_samples": len(session.motionSamples),
"sleep_periods": len(session.sleepPeriods),
}
if !session.sessionDate.IsZero() {
summary["session_date"] = session.sessionDate.Format("2006-01-02")
}
// Add latest sample info
if len(session.breathingSamples) > 0 {
lastBreath := session.breathingSamples[len(session.breathingSamples)-1]
summary["last_breathing_rate"] = lastBreath.RateBPM
summary["last_breathing_time"] = lastBreath.Timestamp.Unix()
}
if len(session.motionSamples) > 0 {
lastMotion := session.motionSamples[len(session.motionSamples)-1]
summary["last_motion_detected"] = lastMotion.MotionDetected
summary["last_motion_time"] = lastMotion.Timestamp.Unix()
}
session.mu.RUnlock()
result = append(result, summary)
}
writeJSON(w, result)
}
// handleGetSession returns details for a specific sleep session.
func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
if h.monitor == nil {
http.Error(w, "sleep monitor not available", http.StatusServiceUnavailable)
return
}
linkID := chi.URLParam(r, "linkID")
if linkID == "" {
http.Error(w, "link_id required", http.StatusBadRequest)
return
}
session := h.monitor.GetAnalyzer().GetSession(linkID)
if session == nil {
http.Error(w, "no sleep session found for this link", http.StatusNotFound)
return
}
// Get metrics first (acquires its own lock) to avoid deadlock
var metrics *SleepMetrics
if len(session.GetBreathingSamples()) > 0 || len(session.GetMotionSamples()) > 0 {
metrics = session.GetMetrics()
}
session.mu.RLock()
defer session.mu.RUnlock()
result := map[string]interface{}{
"link_id": linkID,
"current_state": session.currentState.String(),
"is_active": session.isActive,
"sleep_start_hour": session.sleepStartHour,
"sleep_end_hour": session.sleepEndHour,
"breathing_samples": len(session.breathingSamples),
"motion_samples": len(session.motionSamples),
"sleep_periods": len(session.sleepPeriods),
}
if !session.sessionDate.IsZero() {
result["session_date"] = session.sessionDate.Format("2006-01-02")
}
// Include current period if active
if session.currentPeriod != nil {
result["current_period"] = map[string]interface{}{
"state": session.currentPeriod.State.String(),
"start_time": session.currentPeriod.StartTime.Unix(),
}
}
// Include live metrics if available
if metrics != nil {
metricsMap := map[string]interface{}{
"total_duration_hours": metrics.TotalDuration.Hours(),
"time_in_bed_hours": metrics.TimeInBed.Hours(),
"avg_breathing_rate": metrics.AvgBreathingRate,
"breathing_rate_std_dev": metrics.BreathingRateStdDev,
"breathing_regularity": metrics.BreathingRegularity,
"breathing_score": metrics.BreathingScore,
"breathing_anomaly": metrics.BreathingAnomaly,
"breathing_anomaly_count": metrics.BreathingAnomalyCount,
"quiet_time_pct": metrics.QuietTimePct,
"motion_events": metrics.MotionEvents,
"restless_periods": metrics.RestlessPeriods,
"motion_score": metrics.MotionScore,
"interruptions": metrics.Interruptions,
"longest_deep_period_mins": metrics.LongestDeepPeriod.Minutes(),
"continuity_score": metrics.ContinuityScore,
"overall_score": metrics.OverallScore,
"quality_rating": metrics.QualityRating,
}
if metrics.PersonalAvgBPM > 0 {
metricsMap["personal_avg_bpm"] = metrics.PersonalAvgBPM
}
if !metrics.SleepStartTime.IsZero() {
metricsMap["sleep_start_time"] = metrics.SleepStartTime.Format("15:04")
}
if !metrics.SleepEndTime.IsZero() {
metricsMap["sleep_end_time"] = metrics.SleepEndTime.Format("15:04")
}
result["metrics"] = metricsMap
}
writeJSON(w, result)
}
// handleGetSamples returns the raw samples for a specific session.
func (h *Handler) handleGetSamples(w http.ResponseWriter, r *http.Request) {
if h.monitor == nil {
http.Error(w, "sleep monitor not available", http.StatusServiceUnavailable)
return
}
linkID := chi.URLParam(r, "linkID")
if linkID == "" {
http.Error(w, "link_id required", http.StatusBadRequest)
return
}
session := h.monitor.GetAnalyzer().GetSession(linkID)
if session == nil {
http.Error(w, "no sleep session found for this link", http.StatusNotFound)
return
}
// Get sample type from query params (breathing, motion, or all)
sampleType := r.URL.Query().Get("type")
if sampleType == "" {
sampleType = "all"
}
result := map[string]interface{}{
"link_id": linkID,
}
switch sampleType {
case "breathing":
result["breathing_samples"] = session.GetBreathingSamples()
case "motion":
result["motion_samples"] = session.GetMotionSamples()
default:
result["breathing_samples"] = session.GetBreathingSamples()
result["motion_samples"] = session.GetMotionSamples()
}
// Add sleep periods
session.mu.RLock()
periods := make([]map[string]interface{}, len(session.sleepPeriods))
for i, p := range session.sleepPeriods {
period := map[string]interface{}{
"state": p.State.String(),
"start_time": p.StartTime.Unix(),
"duration_seconds": p.Duration.Seconds(),
}
if !p.EndTime.IsZero() {
period["end_time"] = p.EndTime.Unix()
}
periods[i] = period
}
session.mu.RUnlock()
result["sleep_periods"] = periods
writeJSON(w, result)
}
// handleGetSleepRecords returns sleep records from the main DB.
// GET /api/sleep?person=<name>&limit=30
func (h *Handler) handleGetSleepRecords(w http.ResponseWriter, r *http.Request) {
if h.records == nil {
http.Error(w, "sleep records not available", http.StatusServiceUnavailable)
return
}
person := r.URL.Query().Get("person")
limit := 30
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := time.ParseDuration(l); err == nil {
// Accept "7d", "30" as shorthand for days
limit = int(n.Hours() / 24)
}
}
records, err := h.records.Query(person, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, records)
}
// handleGetSleepSummary returns the most recent sleep summary for a person.
// GET /api/sleep/summary?person=<name>
func (h *Handler) handleGetSleepSummary(w http.ResponseWriter, r *http.Request) {
if h.records == nil {
http.Error(w, "sleep records not available", http.StatusServiceUnavailable)
return
}
person := r.URL.Query().Get("person")
rec, err := h.records.GetSummary(person)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, rec)
}
// SaveRecord persists a sleep report to the main DB sleep_records table.
func (h *Handler) SaveRecord(person string, report *SleepReport) error {
if h.records == nil {
return nil
}
return h.records.Save(person, report)
}
// writeJSON is a helper to write JSON responses.
func writeJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}