diff --git a/internal/client/base.go b/internal/client/base.go index 1a64599..9dfccee 100644 --- a/internal/client/base.go +++ b/internal/client/base.go @@ -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 } @@ -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) { @@ -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 { @@ -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) } diff --git a/internal/client/eightsleep.go b/internal/client/eightsleep.go index 55fad67..5440b4a 100644 --- a/internal/client/eightsleep.go +++ b/internal/client/eightsleep.go @@ -2,6 +2,7 @@ package client import ( "bytes" + "compress/gzip" "context" "crypto/tls" "encoding/json" @@ -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" @@ -34,7 +36,8 @@ type Client struct { DeviceID string HTTP *http.Client - BaseURL string + BaseURL string + AppBaseURL string token string tokenExp time.Time } @@ -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, } } @@ -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)) @@ -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) @@ -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) @@ -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, } diff --git a/internal/cmd/base.go b/internal/cmd/base.go index 91a412f..157cc5f 100644 --- a/internal/cmd/base.go +++ b/internal/cmd/base.go @@ -2,6 +2,8 @@ package cmd import ( "context" + "fmt" + "strconv" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -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 { @@ -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 {