Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ go.work.sum
# Editor/IDE
# .idea/
# .vscode/
eightctl
43 changes: 43 additions & 0 deletions internal/client/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
142 changes: 116 additions & 26 deletions internal/client/eightsleep.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -281,24 +291,33 @@ 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 {
return err
}
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)
Expand Down Expand Up @@ -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.
Expand All @@ -398,19 +480,27 @@ 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)
q.Set("to", date)
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"`
}
Expand Down
57 changes: 44 additions & 13 deletions internal/cmd/sleep.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"context"
"math"
"time"

"github.com/spf13/cobra"
Expand All @@ -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")
}
Expand All @@ -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)
}
Loading