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
99 changes: 91 additions & 8 deletions internal/client/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func (b *BaseActions) Info(ctx context.Context) (any, error) {
}
path := fmt.Sprintf("/users/%s/base", b.c.UserID)
var res any
err := b.c.do(ctx, http.MethodGet, path, nil, nil, &res)
err := b.c.doApp(ctx, http.MethodGet, path, nil, nil, &res)
return res, err
}

Expand All @@ -25,8 +25,8 @@ func (b *BaseActions) SetAngle(ctx context.Context, head, foot int) error {
return err
}
path := fmt.Sprintf("/users/%s/base/angle", b.c.UserID)
body := map[string]any{"head": head, "foot": foot}
return b.c.do(ctx, http.MethodPost, path, nil, body, nil)
body := map[string]any{"torsoAngle": head, "legAngle": foot}
return b.c.doApp(ctx, http.MethodPost, path, nil, body, nil)
}

func (b *BaseActions) Presets(ctx context.Context) (any, error) {
Expand All @@ -35,17 +35,100 @@ func (b *BaseActions) Presets(ctx context.Context) (any, error) {
}
path := fmt.Sprintf("/users/%s/base/presets", b.c.UserID)
var res any
err := b.c.do(ctx, http.MethodGet, path, nil, nil, &res)
err := b.c.doApp(ctx, http.MethodGet, path, nil, nil, &res)
return res, err
}

// RunPreset looks up the preset angles from the API and calls SetAngle to
// physically move the base. Priority order:
// 1. name+"-custom" — user's saved custom variant (e.g. "relaxing-custom")
// 2. Exact name with non-zero angles (e.g. "reading-default", "anti-snore-low")
// 3. First sub-preset with metaOf==name that has non-zero angles
// 4. Exact name even if angles are zero (e.g. "flat" legitimately goes to 0,0)
func (b *BaseActions) RunPreset(ctx context.Context, name string) error {
if err := b.c.requireUser(ctx); err != nil {
return err
}
path := fmt.Sprintf("/users/%s/base/presets", b.c.UserID)
body := map[string]any{"name": name}
return b.c.do(ctx, http.MethodPost, path, nil, body, nil)

// Fetch all presets from API.
presetsRes, err := b.Presets(ctx)
if err != nil {
return fmt.Errorf("fetching presets: %w", err)
}

presetsMap, ok := presetsRes.(map[string]any)
if !ok {
return fmt.Errorf("unexpected presets response type")
}
rawPresets, ok := presetsMap["presets"]
if !ok {
return fmt.Errorf("no 'presets' field in response")
}
presetsList, ok := rawPresets.([]any)
if !ok {
return fmt.Errorf("unexpected presets list type")
}

type presetEntry struct {
torsoAngle float64
legAngle float64
metaOf string
}

byName := make(map[string]presetEntry)
for _, p := range presetsList {
pm, ok := p.(map[string]any)
if !ok {
continue
}
n, _ := pm["name"].(string)
e := presetEntry{}
if v, ok := pm["torsoAngle"].(float64); ok {
e.torsoAngle = v
}
if v, ok := pm["legAngle"].(float64); ok {
e.legAngle = v
}
if v, ok := pm["metaOf"].(string); ok {
e.metaOf = v
}
byName[n] = e
}

// Priority 1: user's custom variant (e.g. "relaxing-custom").
if custom, found := byName[name+"-custom"]; found && (custom.torsoAngle != 0 || custom.legAngle != 0) {
return b.SetAngle(ctx, int(custom.torsoAngle), int(custom.legAngle))
}

// Priority 2: exact match with non-zero angles.
if exact, found := byName[name]; found && (exact.torsoAngle != 0 || exact.legAngle != 0) {
return b.SetAngle(ctx, int(exact.torsoAngle), int(exact.legAngle))
}

// Priority 3: first sub-preset (metaOf==name) with non-zero angles.
// Iterate the original ordered list to prefer defaults that appear first.
for _, p := range presetsList {
pm, ok := p.(map[string]any)
if !ok {
continue
}
metaOf, _ := pm["metaOf"].(string)
if metaOf != name {
continue
}
torso, _ := pm["torsoAngle"].(float64)
leg, _ := pm["legAngle"].(float64)
if torso != 0 || leg != 0 {
return b.SetAngle(ctx, int(torso), int(leg))
}
}

// Priority 4: exact match even with 0/0 angles (e.g. "flat").
if exact, found := byName[name]; found {
return b.SetAngle(ctx, int(exact.torsoAngle), int(exact.legAngle))
}

return fmt.Errorf("preset %q not found", name)
}

func (b *BaseActions) VibrationTest(ctx context.Context) error {
Expand All @@ -54,5 +137,5 @@ func (b *BaseActions) VibrationTest(ctx context.Context) error {
return err
}
path := fmt.Sprintf("/devices/%s/vibration-test", deviceID)
return b.c.do(ctx, http.MethodPost, path, nil, map[string]any{}, nil)
return b.c.doApp(ctx, http.MethodPost, path, nil, map[string]any{}, nil)
}
33 changes: 26 additions & 7 deletions internal/client/eightsleep.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package client

import (
"bytes"
"compress/gzip"
"context"
"crypto/tls"
"encoding/json"
Expand All @@ -17,7 +18,8 @@ import (
)

const (
defaultBaseURL = "https://client-api.8slp.net/v1"
defaultBaseURL = "https://client-api.8slp.net/v1"
defaultAppBaseURL = "https://app-api.8slp.net/v1"
authURL = "https://auth-api.8slp.net/v1/tokens"
// Extracted from the official Eight Sleep Android app v7.39.17 (public client creds)
defaultClientID = "0894c7f33bb94800a03f1f4df13a4f38"
Expand All @@ -34,7 +36,8 @@ type Client struct {
DeviceID string

HTTP *http.Client
BaseURL string
BaseURL string
AppBaseURL string
token string
tokenExp time.Time
}
Expand Down Expand Up @@ -62,6 +65,7 @@ func New(email, password, userID, clientID, clientSecret string) *Client {
ClientSecret: clientSecret,
HTTP: &http.Client{Timeout: 20 * time.Second, Transport: tr},
BaseURL: defaultBaseURL,
AppBaseURL: defaultAppBaseURL,
}
}

Expand Down Expand Up @@ -120,8 +124,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 @@ -288,6 +292,14 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values,
return err
}
defer resp.Body.Close()
var reader io.Reader = resp.Body
if resp.Header.Get("Content-Encoding") == "gzip" {
gr, gerr := gzip.NewReader(resp.Body)
if gerr == nil {
defer gr.Close()
reader = gr
}
}
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(2 * time.Second)
return c.do(ctx, method, path, query, body, out)
Expand All @@ -301,15 +313,22 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values,
return c.do(ctx, method, path, query, body, out)
}
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
b, _ := io.ReadAll(reader)
return fmt.Errorf("api %s %s: %s", method, path, string(b))
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
return json.NewDecoder(reader).Decode(out)
}
return nil
}

func (c *Client) doApp(ctx context.Context, method, path string, query url.Values, body any, out any) error {
saved := c.BaseURL
c.BaseURL = c.AppBaseURL
defer func() { c.BaseURL = saved }()
return c.do(ctx, method, path, query, body, out)
}

// TurnOn powers device on.
func (c *Client) TurnOn(ctx context.Context) error {
return c.setPower(ctx, true)
Expand All @@ -331,7 +350,7 @@ func (c *Client) setPower(ctx context.Context, on bool) error {

func (c *Client) Identity() tokencache.Identity {
return tokencache.Identity{
BaseURL: c.BaseURL,
BaseURL: c.BaseURL,
ClientID: c.ClientID,
Email: c.Email,
}
Expand Down
68 changes: 51 additions & 17 deletions internal/cmd/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cmd

import (
"context"
"fmt"
"strconv"

"github.com/spf13/cobra"
"github.com/spf13/viper"
Expand All @@ -24,15 +26,35 @@ var baseInfoCmd = &cobra.Command{Use: "info", RunE: func(cmd *cobra.Command, arg
return output.Print(output.Format(viper.GetString("output")), []string{"info"}, []map[string]any{{"info": res}})
}}

var baseAngleCmd = &cobra.Command{Use: "angle", RunE: func(cmd *cobra.Command, args []string) error {
if err := requireAuthFields(); err != nil {
return err
}
head := viper.GetInt("head")
foot := viper.GetInt("foot")
cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret"))
return cl.Base().SetAngle(context.Background(), head, foot)
}}
var baseAngleCmd = &cobra.Command{
Use: "angle [head] [foot]",
Short: "Set head and foot angles (positional: angle 20 10, or flags: --head 20 --foot 10)",
Args: cobra.MaximumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
if err := requireAuthFields(); err != nil {
return err
}
head := viper.GetInt("head")
foot := viper.GetInt("foot")
// Positional args override flags if provided.
if len(args) >= 1 {
v, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("invalid head angle %q: %w", args[0], err)
}
head = v
}
if len(args) >= 2 {
v, err := strconv.Atoi(args[1])
if err != nil {
return fmt.Errorf("invalid foot angle %q: %w", args[1], err)
}
foot = v
}
cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret"))
return cl.Base().SetAngle(context.Background(), head, foot)
},
}

var basePresetsCmd = &cobra.Command{Use: "presets", RunE: func(cmd *cobra.Command, args []string) error {
if err := requireAuthFields(); err != nil {
Expand All @@ -46,14 +68,26 @@ var basePresetsCmd = &cobra.Command{Use: "presets", RunE: func(cmd *cobra.Comman
return output.Print(output.Format(viper.GetString("output")), []string{"presets"}, []map[string]any{{"presets": res}})
}}

var basePresetRunCmd = &cobra.Command{Use: "preset-run", RunE: func(cmd *cobra.Command, args []string) error {
if err := requireAuthFields(); err != nil {
return err
}
name := viper.GetString("name")
cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret"))
return cl.Base().RunPreset(context.Background(), name)
}}
var basePresetRunCmd = &cobra.Command{
Use: "preset-run [name]",
Short: "Run a preset by name (positional: preset-run relaxing, or flag: --name relaxing)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := requireAuthFields(); err != nil {
return err
}
name := viper.GetString("name")
// Positional arg overrides flag if provided.
if len(args) >= 1 {
name = args[0]
}
if name == "" {
return fmt.Errorf("preset name required (positional arg or --name flag)")
}
cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret"))
return cl.Base().RunPreset(context.Background(), name)
},
}

var baseTestCmd = &cobra.Command{Use: "test", RunE: func(cmd *cobra.Command, args []string) error {
if err := requireAuthFields(); err != nil {
Expand Down