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/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 55fad67..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)) @@ -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 @@ -256,7 +255,18 @@ func (c *Client) requireUser(ctx context.Context) error { return c.EnsureUserID(ctx) } +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) +} + +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 } @@ -281,7 +291,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 { @@ -289,16 +302,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) @@ -373,23 +392,86 @@ 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"` + 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. +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. @@ -398,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) @@ -410,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 8a22ff1..9604385 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" @@ -24,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") } @@ -33,30 +34,60 @@ 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 + } + // 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, - "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, + "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, + "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", "latency_asleep", "latency_out", "tnt", "resp_rate", "heart_rate", "hrv_score"}, 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) }, } 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 d31f1ec..e7f3842 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" @@ -19,8 +20,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") } @@ -41,24 +42,38 @@ 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 } + r1 := func(v float64) float64 { return math.Round(v*10) / 10 } 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, + "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", "tnt", "resp_rate", "heart_rate", "hrv_score"} + 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") } 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, })