From db95a3cf49c16425fe71ddd26649aea59d25206b Mon Sep 17 00:00:00 2001 From: Dave Fencik Date: Thu, 19 Feb 2026 09:02:05 -0600 Subject: [PATCH 1/6] fix: remove explicit Accept-Encoding: gzip headers Go's http.Transport handles gzip decompression transparently when Accept-Encoding is not set manually. Setting it explicitly disables automatic decompression, causing raw gzip bytes to reach json.Decoder and producing 'invalid character' errors on most endpoints. Fixes #14 --- internal/client/eightsleep.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/client/eightsleep.go b/internal/client/eightsleep.go index 55fad67..59985d4 100644 --- a/internal/client/eightsleep.go +++ b/internal/client/eightsleep.go @@ -182,7 +182,6 @@ func (c *Client) authLegacyLogin(ctx context.Context) error { req.Header.Set("Accept", "application/json") req.Header.Set("Connection", "keep-alive") req.Header.Set("User-Agent", "okhttp/4.9.3") - req.Header.Set("Accept-Encoding", "gzip") resp, err := c.HTTP.Do(req) if err != nil { return err @@ -281,7 +280,10 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, req.Header.Set("Accept", "application/json") req.Header.Set("Connection", "keep-alive") req.Header.Set("User-Agent", "okhttp/4.9.3") - req.Header.Set("Accept-Encoding", "gzip") + // Removed explicit Accept-Encoding: gzip — Go's http.Transport handles + // gzip transparently when the header is not set manually. Setting it + // explicitly disables automatic decompression, causing raw gzip bytes + // to reach the JSON decoder. resp, err := c.HTTP.Do(req) if err != nil { From 84f384bc0c8c45c6ba58f3a896a14919323e8c45 Mon Sep 17 00:00:00 2001 From: Dave Fencik Date: Thu, 19 Feb 2026 09:02:24 -0600 Subject: [PATCH 2/6] fix: add max retries for 429/401 to prevent infinite loops The do() method recursed indefinitely on 429 (rate limit) and 401 (unauthorized) responses. This could hang the CLI forever when the API consistently returns these status codes. Add a retry counter (max 3) with exponential backoff for 429s. --- internal/client/eightsleep.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/internal/client/eightsleep.go b/internal/client/eightsleep.go index 59985d4..57ed94d 100644 --- a/internal/client/eightsleep.go +++ b/internal/client/eightsleep.go @@ -255,7 +255,13 @@ func (c *Client) requireUser(ctx context.Context) error { return c.EnsureUserID(ctx) } +const maxRetries = 3 + func (c *Client) do(ctx context.Context, method, path string, query url.Values, body any, out any) error { + return c.doRetry(ctx, method, path, query, body, out, 0) +} + +func (c *Client) doRetry(ctx context.Context, method, path string, query url.Values, body any, out any, attempt int) error { if err := c.ensureToken(ctx); err != nil { return err } @@ -291,16 +297,22 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, } defer resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests { - time.Sleep(2 * time.Second) - return c.do(ctx, method, path, query, body, out) + if attempt >= maxRetries { + return fmt.Errorf("rate limited after %d retries: %s %s", maxRetries, method, path) + } + time.Sleep(time.Duration(2*(attempt+1)) * time.Second) + return c.doRetry(ctx, method, path, query, body, out, attempt+1) } if resp.StatusCode == http.StatusUnauthorized { + if attempt >= maxRetries { + return fmt.Errorf("unauthorized after %d retries: %s %s", maxRetries, method, path) + } c.token = "" _ = tokencache.Clear(c.Identity()) if err := c.ensureToken(ctx); err != nil { return err } - return c.do(ctx, method, path, query, body, out) + return c.doRetry(ctx, method, path, query, body, out, attempt+1) } if resp.StatusCode >= 300 { b, _ := io.ReadAll(resp.Body) From 92e8e819a4cdda5930a77bf5495f7c32b988475d Mon Sep 17 00:00:00 2001 From: Dave Fencik Date: Thu, 19 Feb 2026 09:02:41 -0600 Subject: [PATCH 3/6] fix: skip macOS Keychain in headless environments to prevent hangs The macOS Keychain backend blocks indefinitely in headless environments (SSH, cron, launchd) when it cannot show the authorization prompt. Detect headless mode via SSH_TTY or missing TERM and fall back to file-only backend. Interactive terminal users keep Keychain as default. Can also be forced with EIGHTCTL_KEYRING_FILE=1. Fixes #10 --- internal/tokencache/tokencache.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/internal/tokencache/tokencache.go b/internal/tokencache/tokencache.go index 9d1c6a6..977bfdc 100644 --- a/internal/tokencache/tokencache.go +++ b/internal/tokencache/tokencache.go @@ -43,14 +43,21 @@ func SetOpenKeyringForTest(fn func() (keyring.Keyring, error)) (restore func()) func defaultOpenKeyring() (keyring.Keyring, error) { home, _ := os.UserHomeDir() + backends := []keyring.BackendType{ + keyring.KeychainBackend, + keyring.SecretServiceBackend, + keyring.WinCredBackend, + keyring.FileBackend, + } + // In headless environments (no terminal), the macOS Keychain backend + // blocks indefinitely waiting for an authorization prompt that can never + // be shown. Fall back to file-only backend in that case. + if os.Getenv("SSH_TTY") != "" || os.Getenv("TERM") == "" || os.Getenv("EIGHTCTL_KEYRING_FILE") == "1" { + backends = []keyring.BackendType{keyring.FileBackend} + } return keyring.Open(keyring.Config{ - ServiceName: serviceName, - AllowedBackends: []keyring.BackendType{ - keyring.KeychainBackend, - keyring.SecretServiceBackend, - keyring.WinCredBackend, - keyring.FileBackend, - }, + ServiceName: serviceName, + AllowedBackends: backends, FileDir: filepath.Join(home, ".config", "eightctl", "keyring"), FilePasswordFunc: filePassword, }) From a41015f7c26601016dab27612af531ed17c15c26 Mon Sep 17 00:00:00 2001 From: Gerry Fencik Date: Fri, 20 Feb 2026 12:32:21 -0600 Subject: [PATCH 4/6] fix: update SleepDay struct to match actual API response Corrects field mappings for sleep data parsing: - sleepDuration (not sleepDurationSeconds) - Adds deepDuration, remDuration, lightDuration, sleepStart/End - Properly nests sleepQualityScore with hrv, heartRate, respiratoryRate - Fixes sleep range --from/--to flag parsing (read from cobra, not viper) - Displays durations in hours for readability Inspired by talison's analysis in steipete/eightctl#11. --- .gitignore | 1 + internal/client/eightsleep.go | 49 +++++++++++++++++++++++------------ internal/cmd/sleep.go | 29 ++++++++++++++------- internal/cmd/sleep_range.go | 26 ++++++++++++------- 4 files changed, 68 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index aaadf73..929a9b5 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ +eightctl diff --git a/internal/client/eightsleep.go b/internal/client/eightsleep.go index 57ed94d..350d197 100644 --- a/internal/client/eightsleep.go +++ b/internal/client/eightsleep.go @@ -387,23 +387,38 @@ func (c *Client) GetStatus(ctx context.Context) (*TempStatus, error) { // SleepDay represents aggregated sleep metrics for a day. 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"` + Date string `json:"day"` + Score float64 `json:"score"` + Tnt int `json:"tnt"` + 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..2d39b8f 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") } From 116d3bb207aa61f2da5038100d467bcbdcfb02ea Mon Sep 17 00:00:00 2001 From: Gerry Fencik Date: Fri, 20 Feb 2026 12:47:12 -0600 Subject: [PATCH 5/6] feat: add full sleep report fields (lowest HR, snoring, awake time, quality) Adds to sleep day/range output: - quality (sleepQualityScore.total) - awake_min (presenceDuration - sleepDuration) - disturbances (renamed from tnt) - avg_rhr (rolling average resting heart rate) - lowest_hr (min from session timeseries heartRate data) - breath_rate (respiratoryRate.current) - snore_min (snoreDuration in minutes) Parses sessions[].timeseries.heartRate array to extract lowest HR. Also adds presenceDuration, snoreDuration, heavySnoreDuration to SleepDay struct. --- internal/client/eightsleep.go | 81 +++++++++++++++++++++++++++++------ internal/cmd/sleep.go | 44 +++++++++++++------ internal/cmd/sleep_range.go | 23 +++++----- 3 files changed, 110 insertions(+), 38 deletions(-) diff --git a/internal/client/eightsleep.go b/internal/client/eightsleep.go index 350d197..1117eb3 100644 --- a/internal/client/eightsleep.go +++ b/internal/client/eightsleep.go @@ -257,6 +257,11 @@ func (c *Client) requireUser(ctx context.Context) error { const maxRetries = 3 +// Do is an exported wrapper around do for debugging/tooling. +func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body any, out any) error { + return c.do(ctx, method, path, query, body, out) +} + func (c *Client) do(ctx context.Context, method, path string, query url.Values, body any, out any) error { return c.doRetry(ctx, method, path, query, body, out, 0) } @@ -387,20 +392,68 @@ func (c *Client) GetStatus(ctx context.Context) (*TempStatus, error) { // SleepDay represents aggregated sleep metrics for a day. type SleepDay struct { - Date string `json:"day"` - Score float64 `json:"score"` - Tnt int `json:"tnt"` - 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"` + Date string `json:"day"` + Score float64 `json:"score"` + Tnt int `json:"tnt"` + Duration float64 `json:"sleepDuration"` + PresenceDuration float64 `json:"presenceDuration"` + 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"` + SnoreDuration float64 `json:"snoreDuration"` + SnorePercent float64 `json:"snorePercent"` + HeavySnoreDuration float64 `json:"heavySnoreDuration"` + HeavySnorePercent float64 `json:"heavySnorePercent"` + SleepQuality SleepQualityScore `json:"sleepQualityScore"` + Sessions []SleepSession `json:"sessions"` +} + +// SleepSession contains per-session data including timeseries. +type SleepSession struct { + ID string `json:"id"` + Timeseries SleepTimeseries `json:"timeseries"` +} + +// SleepTimeseries contains per-minute sensor readings as [timestamp, value] pairs. +type SleepTimeseries struct { + HeartRate [][]any `json:"heartRate"` + HRV [][]any `json:"hrv"` +} + +// LowestHeartRate returns the minimum heart rate value from session timeseries. +func (d *SleepDay) LowestHeartRate() float64 { + min := 0.0 + for _, sess := range d.Sessions { + for _, entry := range sess.Timeseries.HeartRate { + if len(entry) == 2 { + if val, ok := toFloat(entry[1]); ok && val > 0 { + if min == 0 || val < min { + min = val + } + } + } + } + } + return min +} + +func toFloat(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case int: + return float64(n), true + case int64: + return float64(n), true + default: + return 0, false + } } // SleepQualityScore contains detailed sleep quality metrics from the API. diff --git a/internal/cmd/sleep.go b/internal/cmd/sleep.go index bc3d182..6144686 100644 --- a/internal/cmd/sleep.go +++ b/internal/cmd/sleep.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "math" "time" "github.com/spf13/cobra" @@ -37,30 +38,45 @@ 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 + // Convert durations from seconds to hours/minutes for readability + r1 := func(v float64) float64 { return math.Round(v*10) / 10 } + durationHrs := r1(day.Duration / 3600) + deepHrs := r1(day.DeepDuration / 3600) + remHrs := r1(day.RemDuration / 3600) + lightHrs := r1(day.LightDuration / 3600) + awakeMin := r1((day.PresenceDuration - day.Duration) / 60) + snoreMin := r1(day.SnoreDuration / 60) rows := []map[string]any{ { "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, + "quality": day.SleepQuality.Total, + "duration_hrs": durationHrs, + "deep_hrs": deepHrs, + "rem_hrs": remHrs, + "light_hrs": lightHrs, + "awake_min": awakeMin, + "disturbances": day.Tnt, "rhr": day.SleepQuality.HeartRate.Current, + "avg_rhr": day.SleepQuality.HeartRate.Average, + "lowest_hr": day.LowestHeartRate(), "hrv": day.SleepQuality.HRV.Current, - "resp_rate": day.SleepQuality.Respiratory.Current, + "breath_rate": day.SleepQuality.Respiratory.Current, + "snore_min": snoreMin, + "sleep_start": day.SleepStart, + "sleep_end": day.SleepEnd, }, } rows = output.FilterFields(rows, viper.GetStringSlice("fields")) - 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) + return output.Print(output.Format(viper.GetString("output")), []string{ + "date", "score", "quality", "duration_hrs", + "deep_hrs", "rem_hrs", "light_hrs", + "awake_min", "disturbances", + "rhr", "avg_rhr", "lowest_hr", + "hrv", "breath_rate", "snore_min", + "sleep_start", "sleep_end", + }, rows) }, } diff --git a/internal/cmd/sleep_range.go b/internal/cmd/sleep_range.go index 2d39b8f..57b6cd7 100644 --- a/internal/cmd/sleep_range.go +++ b/internal/cmd/sleep_range.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "math" "time" "github.com/spf13/cobra" @@ -47,24 +48,26 @@ 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 - + r1 := func(v float64) float64 { return math.Round(v*10) / 10 } rows = append(rows, map[string]any{ "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, + "quality": day.SleepQuality.Total, + "duration_hrs": r1(day.Duration / 3600), + "deep_hrs": r1(day.DeepDuration / 3600), + "rem_hrs": r1(day.RemDuration / 3600), + "awake_min": r1((day.PresenceDuration - day.Duration) / 60), + "disturbances": day.Tnt, "rhr": day.SleepQuality.HeartRate.Current, + "avg_rhr": day.SleepQuality.HeartRate.Average, + "lowest_hr": day.LowestHeartRate(), "hrv": day.SleepQuality.HRV.Current, + "breath_rate": day.SleepQuality.Respiratory.Current, + "snore_min": r1(day.SnoreDuration / 60), }) } rows = output.FilterFields(rows, viper.GetStringSlice("fields")) - headers := []string{"date", "score", "duration_hrs", "deep_hrs", "rem_hrs", "rhr", "hrv", "tnt"} + headers := []string{"date", "score", "quality", "duration_hrs", "deep_hrs", "rem_hrs", "awake_min", "disturbances", "rhr", "avg_rhr", "lowest_hr", "hrv", "breath_rate", "snore_min"} if len(viper.GetStringSlice("fields")) > 0 { headers = viper.GetStringSlice("fields") } From 45878d6f7f262416eef9e103e624ee40dc77d33b Mon Sep 17 00:00:00 2001 From: Gerry Fencik Date: Fri, 20 Feb 2026 14:12:03 -0600 Subject: [PATCH 6/6] feat: add --side flag, fix --date flag, fix OAuth client_id - Add --side flag to sleep day/range: left, right, partner, or me - Fix --date flag reading from cobra instead of viper (same fix as --from/--to) - Fix OAuth token endpoint using hardcoded 'sleep-client' instead of actual client ID - Add GetSleepDayForUser() to query any user's trends - Add Device().SideUserIDs() and UserIDForSide() helpers --- internal/client/device.go | 43 +++++++++++++++++++++++++++++++++++ internal/client/eightsleep.go | 16 +++++++++---- internal/cmd/sleep.go | 12 +++++++--- internal/cmd/sleep_range.go | 8 ++++++- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/internal/client/device.go b/internal/client/device.go index e571da2..4e9f9c0 100644 --- a/internal/client/device.go +++ b/internal/client/device.go @@ -10,6 +10,49 @@ type DeviceActions struct{ c *Client } func (c *Client) Device() *DeviceActions { return &DeviceActions{c: c} } +// SideUserIDs returns the left and right user IDs from the device. +func (d *DeviceActions) SideUserIDs(ctx context.Context) (left, right string, err error) { + id, err := d.c.EnsureDeviceID(ctx) + if err != nil { + return "", "", err + } + path := fmt.Sprintf("/devices/%s", id) + var res struct { + Result struct { + LeftUserID string `json:"leftUserId"` + RightUserID string `json:"rightUserId"` + } `json:"result"` + } + if err := d.c.do(ctx, http.MethodGet, path, nil, nil, &res); err != nil { + return "", "", err + } + return res.Result.LeftUserID, res.Result.RightUserID, nil +} + +// UserIDForSide returns the user ID for "left", "right", or "partner" (the other side). +func (c *Client) UserIDForSide(ctx context.Context, side string) (string, error) { + if side == "" || side == "me" { + return c.UserID, nil + } + left, right, err := c.Device().SideUserIDs(ctx) + if err != nil { + return "", err + } + switch side { + case "left": + return left, nil + case "right": + return right, nil + case "partner": + if c.UserID == left { + return right, nil + } + return left, nil + default: + return "", fmt.Errorf("invalid side %q: use left, right, partner, or me", side) + } +} + func (d *DeviceActions) Info(ctx context.Context) (any, error) { id, err := d.c.EnsureDeviceID(ctx) if err != nil { diff --git a/internal/client/eightsleep.go b/internal/client/eightsleep.go index 1117eb3..030a08e 100644 --- a/internal/client/eightsleep.go +++ b/internal/client/eightsleep.go @@ -120,8 +120,8 @@ func (c *Client) authTokenEndpoint(ctx context.Context) error { "grant_type": "password", "username": c.Email, "password": c.Password, - "client_id": "sleep-client", - "client_secret": "", + "client_id": c.ClientID, + "client_secret": c.ClientSecret, } body, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL, bytes.NewReader(body)) @@ -480,11 +480,19 @@ type Stage struct { Duration float64 `json:"duration"` } -// GetSleepDay fetches sleep trends for a date (YYYY-MM-DD). +// GetSleepDay fetches sleep trends for a date (YYYY-MM-DD) for the authenticated user. func (c *Client) GetSleepDay(ctx context.Context, date string, timezone string) (*SleepDay, error) { + return c.GetSleepDayForUser(ctx, "", date, timezone) +} + +// GetSleepDayForUser fetches sleep trends for a specific user ID. Empty userID uses the authenticated user. +func (c *Client) GetSleepDayForUser(ctx context.Context, userID, date, timezone string) (*SleepDay, error) { if err := c.requireUser(ctx); err != nil { return nil, err } + if userID == "" { + userID = c.UserID + } q := url.Values{} q.Set("tz", timezone) q.Set("from", date) @@ -492,7 +500,7 @@ func (c *Client) GetSleepDay(ctx context.Context, date string, timezone string) q.Set("include-main", "false") q.Set("include-all-sessions", "true") q.Set("model-version", "v2") - path := fmt.Sprintf("/users/%s/trends", c.UserID) + path := fmt.Sprintf("/users/%s/trends", userID) var res struct { Days []SleepDay `json:"days"` } diff --git a/internal/cmd/sleep.go b/internal/cmd/sleep.go index 6144686..9604385 100644 --- a/internal/cmd/sleep.go +++ b/internal/cmd/sleep.go @@ -25,7 +25,7 @@ var sleepDayCmd = &cobra.Command{ if err := requireAuthFields(); err != nil { return err } - date := viper.GetString("date") + date, _ := cmd.Flags().GetString("date") if date == "" { date = time.Now().Format("2006-01-02") } @@ -34,7 +34,13 @@ var sleepDayCmd = &cobra.Command{ tz = time.Local.String() } cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret")) - day, err := cl.GetSleepDay(context.Background(), date, tz) + ctx := context.Background() + side, _ := cmd.Flags().GetString("side") + userID, err := cl.UserIDForSide(ctx, side) + if err != nil { + return err + } + day, err := cl.GetSleepDayForUser(ctx, userID, date, tz) if err != nil { return err } @@ -82,6 +88,6 @@ var sleepDayCmd = &cobra.Command{ func init() { sleepCmd.PersistentFlags().String("date", "", "date YYYY-MM-DD (default today)") - viper.BindPFlag("date", sleepCmd.PersistentFlags().Lookup("date")) + sleepCmd.PersistentFlags().String("side", "", "bed side: left, right, partner, or me (default: me)") sleepCmd.AddCommand(sleepDayCmd) } diff --git a/internal/cmd/sleep_range.go b/internal/cmd/sleep_range.go index 57b6cd7..e7f3842 100644 --- a/internal/cmd/sleep_range.go +++ b/internal/cmd/sleep_range.go @@ -42,9 +42,15 @@ var sleepRangeCmd = &cobra.Command{ tz = time.Local.String() } cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret")) + ctx := context.Background() + side, _ := cmd.Flags().GetString("side") + userID, err := cl.UserIDForSide(ctx, side) + if err != nil { + return err + } rows := []map[string]any{} for d := start; !d.After(end); d = d.Add(24 * time.Hour) { - day, err := cl.GetSleepDay(context.Background(), d.Format(layout), tz) + day, err := cl.GetSleepDayForUser(ctx, userID, d.Format(layout), tz) if err != nil { return err }