From 8144731250ec882db6449e7db20ca7e873ba1752 Mon Sep 17 00:00:00 2001 From: Tom Alison Date: Fri, 30 Jan 2026 20:42:55 -0800 Subject: [PATCH] Fix sleep data parsing to match actual API response This PR addresses two issues with the sleep commands: 1. **Range command flag parsing** (`sleep_range.go`): The `--from` and `--to` flags were being read via `viper.GetString()` which wasn't receiving the flag values properly. Changed to read directly from cobra's `cmd.Flags().GetString()`. 2. **SleepDay struct field mapping** (`eightsleep.go`): The struct fields didn't match the actual API response structure. The API returns: - `sleepDuration` (not `sleepDurationSeconds`) - `deepDuration`, `remDuration`, `lightDuration` for sleep stages - `sleepStart`, `sleepEnd` timestamps - Nested `sleepQualityScore` with `hrv.current`, `heartRate.current`, etc. Updated the struct and commands to properly parse and display: - Sleep duration in hours - Deep/REM/Light sleep durations - Sleep start/end times - RHR (resting heart rate) - HRV - Respiratory rate Tested against live Eight Sleep API. --- internal/client/eightsleep.go | 43 +++++++++++++++++++++++------------ internal/cmd/sleep.go | 29 +++++++++++++++-------- internal/cmd/sleep_range.go | 26 +++++++++++++-------- 3 files changed, 64 insertions(+), 34 deletions(-) diff --git a/internal/client/eightsleep.go b/internal/client/eightsleep.go index 55fad67..4fe1c54 100644 --- a/internal/client/eightsleep.go +++ b/internal/client/eightsleep.go @@ -376,20 +376,35 @@ type SleepDay struct { Date string `json:"day"` Score float64 `json:"score"` Tnt int `json:"tnt"` - Respiratory float64 `json:"respiratoryRate"` - HeartRate float64 `json:"heartRate"` - LatencyAsleep float64 `json:"latencyAsleepSeconds"` - LatencyOut float64 `json:"latencyOutSeconds"` - Duration float64 `json:"sleepDurationSeconds"` - Stages []Stage `json:"stages"` - SleepQuality struct { - HRV struct { - Score float64 `json:"score"` - } `json:"hrv"` - Resp struct { - Score float64 `json:"score"` - } `json:"respiratoryRate"` - } `json:"sleepQualityScore"` + Duration float64 `json:"sleepDuration"` + DeepDuration float64 `json:"deepDuration"` + RemDuration float64 `json:"remDuration"` + LightDuration float64 `json:"lightDuration"` + DeepPercent float64 `json:"deepPercent"` + RemPercent float64 `json:"remPercent"` + PresenceStart string `json:"presenceStart"` + PresenceEnd string `json:"presenceEnd"` + SleepStart string `json:"sleepStart"` + SleepEnd string `json:"sleepEnd"` + SleepQuality SleepQualityScore `json:"sleepQualityScore"` +} + +// SleepQualityScore contains detailed sleep quality metrics from the API. +type SleepQualityScore struct { + Total float64 `json:"total"` + HRV SleepMetric `json:"hrv"` + HeartRate SleepMetric `json:"heartRate"` + Respiratory SleepMetric `json:"respiratoryRate"` + Deep SleepMetric `json:"deep"` + Rem SleepMetric `json:"rem"` + Waso SleepMetric `json:"waso"` +} + +// SleepMetric represents a single sleep metric with current value and statistics. +type SleepMetric struct { + Current float64 `json:"current"` + Average float64 `json:"average"` + Score float64 `json:"score"` } // Stage represents sleep stage duration. diff --git a/internal/cmd/sleep.go b/internal/cmd/sleep.go index 8a22ff1..bc3d182 100644 --- a/internal/cmd/sleep.go +++ b/internal/cmd/sleep.go @@ -37,21 +37,30 @@ var sleepDayCmd = &cobra.Command{ if err != nil { return err } + // Convert durations from seconds to hours for readability + durationHrs := day.Duration / 3600 + deepHrs := day.DeepDuration / 3600 + remHrs := day.RemDuration / 3600 + lightHrs := day.LightDuration / 3600 + rows := []map[string]any{ { - "date": day.Date, - "score": day.Score, - "tnt": day.Tnt, - "resp_rate": day.Respiratory, - "heart_rate": day.HeartRate, - "duration": day.Duration, - "latency_asleep": day.LatencyAsleep, - "latency_out": day.LatencyOut, - "hrv_score": day.SleepQuality.HRV.Score, + "date": day.Date, + "score": day.Score, + "duration_hrs": float64(int(durationHrs*10)) / 10, + "deep_hrs": float64(int(deepHrs*10)) / 10, + "rem_hrs": float64(int(remHrs*10)) / 10, + "light_hrs": float64(int(lightHrs*10)) / 10, + "sleep_start": day.SleepStart, + "sleep_end": day.SleepEnd, + "tnt": day.Tnt, + "rhr": day.SleepQuality.HeartRate.Current, + "hrv": day.SleepQuality.HRV.Current, + "resp_rate": day.SleepQuality.Respiratory.Current, }, } rows = output.FilterFields(rows, viper.GetStringSlice("fields")) - return output.Print(output.Format(viper.GetString("output")), []string{"date", "score", "duration", "latency_asleep", "latency_out", "tnt", "resp_rate", "heart_rate", "hrv_score"}, rows) + return output.Print(output.Format(viper.GetString("output")), []string{"date", "score", "duration_hrs", "deep_hrs", "rem_hrs", "light_hrs", "rhr", "hrv", "resp_rate", "tnt", "sleep_start", "sleep_end"}, rows) }, } diff --git a/internal/cmd/sleep_range.go b/internal/cmd/sleep_range.go index d31f1ec..bb7e93a 100644 --- a/internal/cmd/sleep_range.go +++ b/internal/cmd/sleep_range.go @@ -19,8 +19,8 @@ var sleepRangeCmd = &cobra.Command{ if err := requireAuthFields(); err != nil { return err } - from := viper.GetString("from") - to := viper.GetString("to") + from, _ := cmd.Flags().GetString("from") + to, _ := cmd.Flags().GetString("to") if from == "" || to == "" { return fmt.Errorf("--from and --to are required") } @@ -47,18 +47,24 @@ var sleepRangeCmd = &cobra.Command{ if err != nil { return err } + // Convert durations from seconds to hours for readability + durationHrs := day.Duration / 3600 + deepHrs := day.DeepDuration / 3600 + remHrs := day.RemDuration / 3600 + rows = append(rows, map[string]any{ - "date": day.Date, - "score": day.Score, - "duration": day.Duration, - "tnt": day.Tnt, - "resp_rate": day.Respiratory, - "heart_rate": day.HeartRate, - "hrv_score": day.SleepQuality.HRV.Score, + "date": day.Date, + "score": day.Score, + "duration_hrs": float64(int(durationHrs*10)) / 10, + "deep_hrs": float64(int(deepHrs*10)) / 10, + "rem_hrs": float64(int(remHrs*10)) / 10, + "tnt": day.Tnt, + "rhr": day.SleepQuality.HeartRate.Current, + "hrv": day.SleepQuality.HRV.Current, }) } rows = output.FilterFields(rows, viper.GetStringSlice("fields")) - headers := []string{"date", "score", "duration", "tnt", "resp_rate", "heart_rate", "hrv_score"} + headers := []string{"date", "score", "duration_hrs", "deep_hrs", "rem_hrs", "rhr", "hrv", "tnt"} if len(viper.GetStringSlice("fields")) > 0 { headers = viper.GetStringSlice("fields") }