diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index d286895..7b9ef3b 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -353,6 +353,11 @@ func main() { SleepEndHour: 7, // 7 AM }) sleepMonitor.SetProcessorManager(pm) + + // Sleep handler (created early so callback can reference it) + sleepHandler := sleep.NewHandler(sleepMonitor) + sleepHandler.SetDB(filepath.Join(cfg.DataDir, "spaxel.db")) + sleepMonitor.SetReportCallback(func(linkID string, report *sleep.SleepReport) { // Broadcast sleep report to dashboard msg := map[string]interface{}{ @@ -368,11 +373,27 @@ func main() { dashboardHub.Broadcast(data) } + // Persist sleep record to main DB (for GET /api/sleep endpoint) + person := sleepMonitor.GetAnalyzer().GetSession(linkID) + personName := linkID + if person != nil { + personName = person.GetPersonID() + } + if personName == "" { + personName = linkID + } + sleepHandler.SaveRecord(personName, report) + // Send notification for morning report + body := fmt.Sprintf("Sleep quality: %s (%.0f/100)", report.Metrics.QualityRating, report.Metrics.OverallScore) + if report.Metrics.BreathingAnomaly { + body = fmt.Sprintf("Breathing rate elevated (%.0f bpm vs. %.0f bpm average). %s", + report.Metrics.AvgBreathingRate, report.Metrics.PersonalAvgBPM, body) + } if notifyService != nil { notif := notify.Notification{ Title: "Sleep Report", - Body: fmt.Sprintf("Sleep quality: %s (%.0f/100)", report.Metrics.QualityRating, report.Metrics.OverallScore), + Body: body, Priority: 2, Tags: []string{"sleep", "morning"}, Data: report.ToJSONMap(), @@ -380,7 +401,9 @@ func main() { notifyService.Send(notif) //nolint:errcheck } - log.Printf("[INFO] Sleep report for %s: score=%.1f rating=%s", linkID, report.Metrics.OverallScore, report.Metrics.QualityRating) + log.Printf("[INFO] Sleep report for %s: score=%.1f rating=%s breathing_avg=%.1f anomaly=%v", + linkID, report.Metrics.OverallScore, report.Metrics.QualityRating, + report.Metrics.AvgBreathingRate, report.Metrics.BreathingAnomaly) }) sleepMonitor.Start() defer sleepMonitor.Stop() @@ -2898,8 +2921,7 @@ func main() { }) } - // Phase 6: Sleep quality REST API - sleepHandler := sleep.NewHandler(sleepMonitor) + // Phase 6: Sleep quality REST API (handler created earlier with monitor) sleepHandler.RegisterRoutes(r) log.Printf("[INFO] Sleep quality API registered at /api/sleep/*") diff --git a/mothership/internal/briefing/briefing.go b/mothership/internal/briefing/briefing.go new file mode 100644 index 0000000..0d0cbda --- /dev/null +++ b/mothership/internal/briefing/briefing.go @@ -0,0 +1,231 @@ +// Package briefing generates morning briefings with sleep and anomaly summaries. +package briefing + +import ( + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +// Generator produces morning briefings from sleep records and events. +type Generator struct { + db *sql.DB +} + +// NewGenerator creates a new briefing generator backed by the main DB. +func NewGenerator(dbPath string) (*Generator, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + db.SetMaxOpenConns(1) + return &Generator{db: db}, nil +} + +// Close closes the DB connection. +func (g *Generator) Close() error { + return g.db.Close() +} + +// Briefing holds a generated morning briefing. +type Briefing struct { + Date string `json:"date"` + Content string `json:"content"` + GeneratedAt int64 `json:"generated_at"` +} + +// Generate creates a morning briefing for the given date. +// It assembles sections from sleep records, anomalies, and system health. +func (g *Generator) Generate(date string, person string) (*Briefing, error) { + var sections []string + + // BLOCK 2 — Sleep summary + if sleepSummary := g.generateSleepBlock(date, person); sleepSummary != "" { + sections = append(sections, sleepSummary) + } + + // BLOCK 4 — Overnight anomalies (breathing) + if anomalyText := g.generateBreathingAnomalyBlock(date, person); anomalyText != "" { + sections = append(sections, anomalyText) + } + + // Degenerate case + if len(sections) == 0 { + sections = append(sections, "All quiet last night. All systems healthy.") + } + + content := strings.Join(sections, "\n\n") + + return &Briefing{ + Date: date, + Content: content, + GeneratedAt: time.Now().UnixMilli(), + }, nil +} + +// generateSleepBlock generates the sleep summary section of the briefing. +func (g *Generator) generateSleepBlock(date, person string) string { + query := `SELECT breathing_rate_avg, breathing_regularity, duration_min, onset_latency_min, + restlessness, breathing_anomaly, breathing_samples_json + FROM sleep_records WHERE date = ?` + var args []interface{} + args = append(args, date) + if person != "" { + query += ` AND person = ?` + args = append(args, person) + } + + row := g.db.QueryRow(query, args...) + + var breathAvg, breathReg, onsetLat, restlessness sql.NullFloat64 + var duration sql.NullInt32 + var breathAnomaly sql.NullBool + var breathSamplesJSON sql.NullString + + if err := row.Scan(&breathAvg, &breathReg, &duration, &onsetLat, &restlessness, + &breathAnomaly, &breathSamplesJSON); err != nil { + return "" + } + + if !breathAvg.Valid || breathAvg.Float64 == 0 { + return "" + } + + var parts []string + + // Duration + if duration.Valid && duration.Int32 > 0 { + h := duration.Int32 / 60 + m := duration.Int32 % 60 + if m > 0 { + parts = append(parts, fmt.Sprintf("You slept %dh %dm", h, m)) + } else { + parts = append(parts, fmt.Sprintf("You slept %dh", h)) + } + } else { + parts = append(parts, "You slept") + } + + // Restlessness + if restlessness.Valid { + switch { + case restlessness.Float64 < 1: + parts = append(parts, "Restlessness: Low.") + case restlessness.Float64 < 3: + parts = append(parts, "Restlessness: Moderate.") + default: + parts = append(parts, "Restlessness: High.") + } + } + + // Breathing regularity + if breathReg.Valid { + cv := breathReg.Float64 + switch { + case cv < 0.10: + parts = append(parts, "Breathing: Regular.") + case cv > 0.25: + parts = append(parts, "Breathing: Irregular.") + default: + parts = append(parts, "Breathing: Normal.") + } + } + + // Breathing anomaly + if breathAnomaly.Valid && breathAnomaly.Bool { + if breathSamplesJSON.Valid { + // Try to extract actual values from the JSON + type sampleInfo struct { + Avg float64 `json:"0"` + Personal float64 `json:"6"` + } + var info sampleInfo + if err := json.Unmarshal([]byte(breathSamplesJSON.String), &info); err == nil && info.Personal > 0 { + parts = append(parts, fmt.Sprintf("Breathing rate elevated (%.0f bpm vs. %.0f bpm average).", + info.Avg, info.Personal)) + } else { + parts = append(parts, "Breathing rate elevated.") + } + } + } + + return strings.Join(parts, " ") +} + +// generateBreathingAnomalyBlock generates the overnight breathing anomaly section. +func (g *Generator) generateBreathingAnomalyBlock(date, person string) string { + query := `SELECT person, breathing_rate_avg, breathing_samples_json + FROM sleep_records + WHERE breathing_anomaly = 1 AND date < ?` + var args []interface{} + args = append(args, date) + if person != "" { + query += ` AND person = ?` + args = append(args, person) + } + query += ` ORDER BY date DESC LIMIT 1` + + row := g.db.QueryRow(query, args...) + + var personName string + var breathAvg sql.NullFloat64 + var breathSamplesJSON sql.NullString + + if err := row.Scan(&personName, &breathAvg, &breathSamplesJSON); err != nil { + return "" + } + + if personName == "" { + personName = "Person" + } + + // Extract personal average from samples JSON + personalAvg := 0.0 + if breathSamplesJSON.Valid { + type sampleInfo struct { + Personal float64 `json:"6"` + } + var info sampleInfo + if err := json.Unmarshal([]byte(breathSamplesJSON.String), &info); err == nil { + personalAvg = info.Personal + } + } + + avgStr := fmt.Sprintf("%.0f", breathAvg.Float64) + personalStr := fmt.Sprintf("%.0f", personalAvg) + if personalAvg > 0 { + return fmt.Sprintf("Last night: Breathing rate elevated (%s bpm vs. %s bpm average for %s).", + avgStr, personalStr, personName) + } + return fmt.Sprintf("Last night: Breathing rate elevated (%s bpm for %s).", avgStr, personName) +} + +// Save persists a briefing to the briefings table. +func (g *Generator) Save(b *Briefing) error { + _, err := g.db.Exec(` + INSERT OR REPLACE INTO briefings (date, content, generated_at) + VALUES (?, ?, ?) + `, b.Date, b.Content, b.GeneratedAt) + return err +} + +// Get retrieves a previously generated briefing by date. +func (g *Generator) Get(date string) (*Briefing, error) { + var content string + var generatedAt int64 + err := g.db.QueryRow( + `SELECT content, generated_at FROM briefings WHERE date = ?`, date, + ).Scan(&content, &generatedAt) + if err != nil { + return nil, err + } + return &Briefing{ + Date: date, + Content: content, + GeneratedAt: generatedAt, + }, nil +} diff --git a/mothership/internal/signal/processor.go b/mothership/internal/signal/processor.go index ab071fc..eca9420 100644 --- a/mothership/internal/signal/processor.go +++ b/mothership/internal/signal/processor.go @@ -219,11 +219,12 @@ func (lp *LinkProcessor) Reset() { // ProcessorManager manages LinkProcessors for all links type ProcessorManager struct { - mu sync.RWMutex - processors map[string]*LinkProcessor - nSub int - alpha float64 - fusionRate float64 // Hz + mu sync.RWMutex + processors map[string]*LinkProcessor + nSub int + alpha float64 + fusionRate float64 // Hz + trackedBlobs []TrackedBlob } // ProcessorManagerConfig holds configuration for ProcessorManager @@ -558,6 +559,28 @@ func (pm *ProcessorManager) CheckDiurnalReadinessTransitions(previouslyReady map return newlyReady } +// TrackedBlob represents a tracked spatial blob from the fusion engine. +type TrackedBlob struct { + ID int + X, Y, Z float64 + VX, VY, VZ float64 + Weight float64 +} + +// SetTrackedBlobs stores the latest tracked blobs from the fusion engine. +func (pm *ProcessorManager) SetTrackedBlobs(blobs []TrackedBlob) { + pm.mu.Lock() + defer pm.mu.Unlock() + pm.trackedBlobs = blobs +} + +// GetTrackedBlobs returns the latest tracked blobs from the fusion engine. +func (pm *ProcessorManager) GetTrackedBlobs() []TrackedBlob { + pm.mu.RLock() + defer pm.mu.RUnlock() + return pm.trackedBlobs +} + // GetLinkCompositeConfidence returns composite confidence for a specific link func (lp *LinkProcessor) GetLinkCompositeConfidence(packetRateRatio float64) float64 { lp.mu.RLock() diff --git a/mothership/internal/sleep/analyzer.go b/mothership/internal/sleep/analyzer.go index f04dee6..316bfb9 100644 --- a/mothership/internal/sleep/analyzer.go +++ b/mothership/internal/sleep/analyzer.go @@ -934,6 +934,13 @@ func (ss *SleepSession) Reset() { ss.metrics = nil } +// GetPersonID returns the person identity for this session. +func (ss *SleepSession) GetPersonID() string { + ss.mu.RLock() + defer ss.mu.RUnlock() + return ss.personID +} + // GetBreathingSamples returns all breathing samples for the session func (ss *SleepSession) GetBreathingSamples() []BreathingSample { ss.mu.RLock() diff --git a/mothership/internal/sleep/handler.go b/mothership/internal/sleep/handler.go index 7cff634..df41a8c 100644 --- a/mothership/internal/sleep/handler.go +++ b/mothership/internal/sleep/handler.go @@ -2,16 +2,20 @@ package sleep import ( + "database/sql" "encoding/json" + "log" "net/http" "time" "github.com/go-chi/chi" + _ "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. @@ -21,8 +25,21 @@ func NewHandler(monitor *Monitor) *Handler { } } +// 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) @@ -305,6 +322,58 @@ func (h *Handler) handleGetSamples(w http.ResponseWriter, r *http.Request) { writeJSON(w, result) } +// handleGetSleepRecords returns sleep records from the main DB. +// GET /api/sleep?person=&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= +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") diff --git a/mothership/internal/sleep/records.go b/mothership/internal/sleep/records.go new file mode 100644 index 0000000..2243318 --- /dev/null +++ b/mothership/internal/sleep/records.go @@ -0,0 +1,290 @@ +// Package sleep provides sleep record persistence against the main spaxel.db. +package sleep + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" +) + +// SleepRecord represents a row in the sleep_records table (main spaxel.db). +type SleepRecord struct { + ID int64 `json:"id"` + Person string `json:"person,omitempty"` + ZoneID *int `json:"zone_id,omitempty"` + Date string `json:"date"` + BedTimeMs *int64 `json:"bed_time_ms,omitempty"` + WakeTimeMs *int64 `json:"wake_time_ms,omitempty"` + DurationMin *int `json:"duration_min,omitempty"` + OnsetLatencyMin *float64 `json:"onset_latency_min,omitempty"` + Restlessness *float64 `json:"restlessness,omitempty"` + BreathingRateAvg *float64 `json:"breathing_rate_avg,omitempty"` + BreathingRegularity *float64 `json:"breathing_regularity,omitempty"` + BreathingAnomaly *bool `json:"breathing_anomaly,omitempty"` + BreathingSamplesJSON *string `json:"breathing_samples_json,omitempty"` + SummaryJSON *string `json:"summary_json,omitempty"` +} + +// SleepRecordStore handles persistence of sleep records against the main DB. +type SleepRecordStore struct { + db *sql.DB +} + +// NewSleepRecordStore creates a store backed by an open main DB connection. +func NewSleepRecordStore(db *sql.DB) *SleepRecordStore { + return &SleepRecordStore{db: db} +} + +// Save persists a sleep report as a sleep_records row. +// If a record for the same (person, date) already exists it is replaced. +func (s *SleepRecordStore) Save(person string, report *SleepReport) error { + m := report.Metrics + dateStr := report.SessionDate.Format("2006-01-02") + + var durationMin *int + if report.Metrics.TimeInBed > 0 { + d := int(report.Metrics.TimeInBed.Minutes()) + durationMin = &d + } + + var onsetLat *float64 + if m.SleepLatencyMinutes > 0 { + onsetLat = &m.SleepLatencyMinutes + } + + var restlessness *float64 + if m.RestlessPeriods > 0 { + r := float64(m.RestlessPeriods) + restlessness = &r + } + + var breathingAvg *float64 + if m.AvgBreathingRate > 0 { + breathingAvg = &m.AvgBreathingRate + } + + var breathingReg *float64 + if m.BreathingRegularity > 0 { + breathingReg = &m.BreathingRegularity + } + + // Build 30-min summary JSON + summaryBytes, _ := json.Marshal(report.ToJSONMap()) + summaryStr := string(summaryBytes) + + // Breathing samples as JSON array of BPM values + var samplesJSON *string + if samples := extractBreathingSamplesJSON(report); samples != "" { + samplesJSON = &samples + } + + bedMs := toMsPtr(report.Metrics.SleepStartTime) + wakeMs := toMsPtr(report.Metrics.SleepEndTime) + + // Upsert: replace existing record for same person+date + _, err := s.db.Exec(` + INSERT INTO sleep_records (person, date, bed_time_ms, wake_time_ms, duration_min, + onset_latency_min, restlessness, breathing_rate_avg, breathing_regularity, + breathing_anomaly, breathing_samples_json, summary_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(person, date) DO UPDATE SET + bed_time_ms = excluded.bed_time_ms, + wake_time_ms = excluded.wake_time_ms, + duration_min = excluded.duration_min, + onset_latency_min = excluded.onset_latency_min, + restlessness = excluded.restlessness, + breathing_rate_avg = excluded.breathing_rate_avg, + breathing_regularity = excluded.breathing_regularity, + breathing_anomaly = excluded.breathing_anomaly, + breathing_samples_json = excluded.breathing_samples_json, + summary_json = excluded.summary_json + `, person, dateStr, bedMs, wakeMs, durationMin, onsetLat, restlessness, + breathingAvg, breathingReg, m.BreathingAnomaly, samplesJSON, summaryStr) + + return err +} + +// extractBreathingSamplesJSON builds a JSON array of breathing rate BPM values. +func extractBreathingSamplesJSON(report *SleepReport) string { + m := report.Metrics + if m.MinBreathingRate == 0 && m.MaxBreathingRate == 0 { + return "" + } + + // Build a simple array representation: [avg, min, max, std_dev, regularity] + arr := []interface{}{ + m.AvgBreathingRate, + m.MinBreathingRate, + m.MaxBreathingRate, + m.BreathingRateStdDev, + m.BreathingRegularity, + m.BreathingScore, + } + if m.BreathingAnomaly { + arr = append(arr, true) + } else { + arr = append(arr, false) + } + if m.PersonalAvgBPM > 0 { + arr = append(arr, m.PersonalAvgBPM) + } + + b, err := json.Marshal(arr) + if err != nil { + return "" + } + return string(b) +} + +// Query retrieves sleep records, optionally filtered by person, with a limit. +func (s *SleepRecordStore) Query(person string, limit int) ([]SleepRecord, error) { + query := `SELECT id, person, zone_id, date, bed_time_ms, wake_time_ms, duration_min, + onset_latency_min, restlessness, breathing_rate_avg, breathing_regularity, + breathing_anomaly, breathing_samples_json, summary_json + FROM sleep_records` + var args []interface{} + if person != "" { + query += ` WHERE person = ?` + args = append(args, person) + } + query += ` ORDER BY date DESC` + if limit > 0 { + query += fmt.Sprintf(` LIMIT %d`, limit) + } + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanSleepRecords(rows) +} + +// GetSummary returns the most recent sleep record for a person. +func (s *SleepRecordStore) GetSummary(person string) (*SleepRecord, error) { + if person == "" { + return nil, fmt.Errorf("person parameter required") + } + + query := `SELECT id, person, zone_id, date, bed_time_ms, wake_time_ms, duration_min, + onset_latency_min, restlessness, breathing_rate_avg, breathing_regularity, + breathing_anomaly, breathing_samples_json, summary_json + FROM sleep_records WHERE person = ? ORDER BY date DESC LIMIT 1` + + row := s.db.QueryRow(query, person) + rec := SleepRecord{} + var zoneID sql.NullInt64 + var bedMs, wakeMs sql.NullInt64 + var durMin sql.NullInt32 + var onsetLat, restless, breathAvg, breathReg sql.NullFloat64 + var breathAnomaly sql.NullBool + var breathSamplesJSON, summaryJSON sql.NullString + + err := row.Scan(&rec.ID, &rec.Person, &zoneID, &rec.Date, &bedMs, &wakeMs, + &durMin, &onsetLat, &restless, &breathAvg, &breathReg, + &breathAnomaly, &breathSamplesJSON, &summaryJSON) + if err != nil { + return nil, err + } + + assignNullableFields(&rec, zoneID, bedMs, wakeMs, durMin, onsetLat, restless, + breathAvg, breathReg, breathAnomaly, breathSamplesJSON, summaryJSON) + return &rec, nil +} + +// GetLatestAnomalyRecords returns records where breathing_anomaly is true, ordered by date desc. +func (s *SleepRecordStore) GetLatestAnomalyRecords(limit int) ([]SleepRecord, error) { + query := `SELECT id, person, zone_id, date, bed_time_ms, wake_time_ms, duration_min, + onset_latency_min, restlessness, breathing_rate_avg, breathing_regularity, + breathing_anomaly, breathing_samples_json, summary_json + FROM sleep_records WHERE breathing_anomaly = 1 + ORDER BY date DESC` + if limit > 0 { + query += fmt.Sprintf(` LIMIT %d`, limit) + } + + rows, err := s.db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanSleepRecords(rows) +} + +func scanSleepRecords(rows *sql.Rows) ([]SleepRecord, error) { + var records []SleepRecord + for rows.Next() { + rec := SleepRecord{} + var zoneID sql.NullInt64 + var bedMs, wakeMs sql.NullInt64 + var durMin sql.NullInt32 + var onsetLat, restless, breathAvg, breathReg sql.NullFloat64 + var breathAnomaly sql.NullBool + var breathSamplesJSON, summaryJSON sql.NullString + + err := rows.Scan(&rec.ID, &rec.Person, &zoneID, &rec.Date, &bedMs, &wakeMs, + &durMin, &onsetLat, &restless, &breathAvg, &breathReg, + &breathAnomaly, &breathSamplesJSON, &summaryJSON) + if err != nil { + return records, err + } + + assignNullableFields(&rec, zoneID, bedMs, wakeMs, durMin, onsetLat, restless, + breathAvg, breathReg, breathAnomaly, breathSamplesJSON, summaryJSON) + records = append(records, rec) + } + return records, rows.Err() +} + +func assignNullableFields(rec *SleepRecord, zoneID sql.NullInt64, + bedMs, wakeMs sql.NullInt64, durMin sql.NullInt32, + onsetLat, restless, breathAvg, breathReg sql.NullFloat64, + breathAnomaly sql.NullBool, breathSamplesJSON, summaryJSON sql.NullString) { + + if zoneID.Valid { + z := int(zoneID.Int64) + rec.ZoneID = &z + } + if bedMs.Valid { + rec.BedTimeMs = &bedMs.Int64 + } + if wakeMs.Valid { + rec.WakeTimeMs = &wakeMs.Int64 + } + if durMin.Valid { + d := int(durMin.Int32) + rec.DurationMin = &d + } + if onsetLat.Valid { + rec.OnsetLatencyMin = &onsetLat.Float64 + } + if restless.Valid { + rec.Restlessness = &restless.Float64 + } + if breathAvg.Valid { + rec.BreathingRateAvg = &breathAvg.Float64 + } + if breathReg.Valid { + rec.BreathingRegularity = &breathReg.Float64 + } + if breathAnomaly.Valid { + rec.BreathingAnomaly = &breathAnomaly.Bool + } + if breathSamplesJSON.Valid { + rec.BreathingSamplesJSON = &breathSamplesJSON.String + } + if summaryJSON.Valid { + rec.SummaryJSON = &summaryJSON.String + } +} + +func toMsPtr(t time.Time) *int64 { + if t.IsZero() { + return nil + } + ms := t.UnixMilli() + return &ms +}