diff --git a/internal/client/device.go b/internal/client/device.go index e571da2..df14940 100644 --- a/internal/client/device.go +++ b/internal/client/device.go @@ -6,10 +6,32 @@ import ( "net/http" ) +// DeviceSides holds the user IDs assigned to each side of the pod. +type DeviceSides struct { + LeftUserID string `json:"leftUserId"` + RightUserID string `json:"rightUserId"` +} + type DeviceActions struct{ c *Client } func (c *Client) Device() *DeviceActions { return &DeviceActions{c: c} } +// Sides fetches the left/right user ID assignments from device info. +func (d *DeviceActions) Sides(ctx context.Context) (*DeviceSides, error) { + id, err := d.c.EnsureDeviceID(ctx) + if err != nil { + return nil, err + } + path := fmt.Sprintf("/devices/%s", id) + var res struct { + Result DeviceSides `json:"result"` + } + if err := d.c.do(ctx, http.MethodGet, path, nil, nil, &res); err != nil { + return nil, err + } + return &res.Result, nil +} + 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 33860b7..f1fcf24 100644 --- a/internal/client/eightsleep.go +++ b/internal/client/eightsleep.go @@ -23,8 +23,11 @@ const ( defaultClientSecret = "f0954a3ed5763ba3d06834c73731a32f15f168f47d4f164751275def86db0c76" ) -// authURL is a var so tests can point it at a local server. -var authURL = "https://auth-api.8slp.net/v1/tokens" +// authURL and appAPIBaseURL are vars so tests can point them at local servers. +var ( + authURL = "https://auth-api.8slp.net/v1/tokens" + appAPIBaseURL = "https://app-api.8slp.net/v1" +) // Client represents Eight Sleep API client. type Client struct { @@ -198,11 +201,23 @@ func (c *Client) requireUser(ctx context.Context) error { const maxRetries = 3 +// do builds a URL from BaseURL + path and delegates to doURL. 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) + u := c.BaseURL + path + if len(query) > 0 { + u += "?" + query.Encode() + } + return c.doURL(ctx, method, u, body, out) } -func (c *Client) doRetry(ctx context.Context, method, path string, query url.Values, body any, out any, attempt int) error { +// doURL sends an authenticated request to an absolute URL. Use do() for +// BaseURL-relative paths; use doURL directly for requests to other hosts +// (e.g. the app API for away mode). +func (c *Client) doURL(ctx context.Context, method, u string, body any, out any) error { + return c.doURLRetry(ctx, method, u, body, out, 0) +} + +func (c *Client) doURLRetry(ctx context.Context, method, u string, body any, out any, attempt int) error { if err := c.ensureToken(ctx); err != nil { return err } @@ -214,10 +229,6 @@ func (c *Client) doRetry(ctx context.Context, method, path string, query url.Val } rdr = bytes.NewReader(b) } - u := c.BaseURL + path - if len(query) > 0 { - u += "?" + query.Encode() - } req, err := http.NewRequestWithContext(ctx, method, u, rdr) if err != nil { return err @@ -238,25 +249,25 @@ func (c *Client) doRetry(ctx context.Context, method, path string, query url.Val defer resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests { if attempt >= maxRetries { - return fmt.Errorf("rate limited after %d retries: %s %s", maxRetries, method, path) + return fmt.Errorf("rate limited after %d retries: %s %s", maxRetries, method, u) } time.Sleep(time.Duration(2*(attempt+1)) * time.Second) - return c.doRetry(ctx, method, path, query, body, out, attempt+1) + return c.doURLRetry(ctx, method, u, body, out, attempt+1) } if resp.StatusCode == http.StatusUnauthorized { if attempt >= maxRetries { - return fmt.Errorf("unauthorized after %d retries: %s %s", maxRetries, method, path) + return fmt.Errorf("unauthorized after %d retries: %s %s", maxRetries, method, u) } c.token = "" _ = tokencache.Clear(c.Identity()) if err := c.ensureToken(ctx); err != nil { return err } - return c.doRetry(ctx, method, path, query, body, out, attempt+1) + return c.doURLRetry(ctx, method, u, body, out, attempt+1) } if resp.StatusCode >= 300 { b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("api %s %s: %s", method, path, string(b)) + return fmt.Errorf("api %s %s: %s", method, u, string(b)) } if out != nil { return json.NewDecoder(resp.Body).Decode(out) @@ -304,6 +315,28 @@ func (c *Client) SetTemperature(ctx context.Context, level int) error { return c.do(ctx, http.MethodPut, path, nil, body, nil) } +// SetAwayMode activates or deactivates away mode for a specific user ID. +// The away-mode endpoint lives on the app API (app-api.8slp.net), not the +// client API used by most other endpoints. +// If userID is empty, it defaults to the authenticated user. +func (c *Client) SetAwayMode(ctx context.Context, userID string, away bool) error { + if userID == "" { + if err := c.requireUser(ctx); err != nil { + return err + } + userID = c.UserID + } + ts := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02T15:04:05.000Z") + var payload map[string]any + if away { + payload = map[string]any{"awayPeriod": map[string]string{"start": ts}} + } else { + payload = map[string]any{"awayPeriod": map[string]string{"end": ts}} + } + u := fmt.Sprintf("%s/users/%s/away-mode", appAPIBaseURL, userID) + return c.doURL(ctx, http.MethodPut, u, payload, nil) +} + // TempStatus represents current temperature state payload. type TempStatus struct { CurrentLevel int `json:"currentLevel"` diff --git a/internal/client/eightsleep_test.go b/internal/client/eightsleep_test.go index 4a4f821..7e75f7a 100644 --- a/internal/client/eightsleep_test.go +++ b/internal/client/eightsleep_test.go @@ -204,6 +204,99 @@ func Test429Retry(t *testing.T) { } } +func TestSetAwayMode(t *testing.T) { + var gotMethod, gotPath string + var gotBody map[string]any + + mux := http.NewServeMux() + mux.HandleFunc("/users/me", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"user":{"userId":"uid-123","currentDevice":{"id":"dev-1"}}}`)) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusOK) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + old := appAPIBaseURL + appAPIBaseURL = srv.URL + defer func() { appAPIBaseURL = old }() + + c := New("e", "p", "uid-123", "", "") + c.BaseURL = srv.URL + c.token = "t" + c.tokenExp = time.Now().Add(time.Hour) + c.HTTP = srv.Client() + + // Test activate + if err := c.SetAwayMode(context.Background(), "", true); err != nil { + t.Fatalf("SetAwayMode on: %v", err) + } + if gotMethod != http.MethodPut { + t.Errorf("method = %q, want PUT", gotMethod) + } + if gotPath != "/users/uid-123/away-mode" { + t.Errorf("path = %q, want /users/uid-123/away-mode", gotPath) + } + period, ok := gotBody["awayPeriod"].(map[string]any) + if !ok { + t.Fatal("missing awayPeriod in body") + } + if _, ok := period["start"]; !ok { + t.Error("activate should send awayPeriod.start") + } + + // Test deactivate + if err := c.SetAwayMode(context.Background(), "uid-456", false); err != nil { + t.Fatalf("SetAwayMode off: %v", err) + } + if gotPath != "/users/uid-456/away-mode" { + t.Errorf("path = %q, want /users/uid-456/away-mode", gotPath) + } + period, ok = gotBody["awayPeriod"].(map[string]any) + if !ok { + t.Fatal("missing awayPeriod in body") + } + if _, ok := period["end"]; !ok { + t.Error("deactivate should send awayPeriod.end") + } +} + +func TestDeviceSides(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/users/me", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"user":{"userId":"uid-123","currentDevice":{"id":"dev-1"}}}`)) + }) + mux.HandleFunc("/devices/dev-1", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"result":{"leftUserId":"uid-left","rightUserId":"uid-right"}}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New("e", "p", "", "", "") + c.BaseURL = srv.URL + c.token = "t" + c.tokenExp = time.Now().Add(time.Hour) + c.HTTP = srv.Client() + + sides, err := c.Device().Sides(context.Background()) + if err != nil { + t.Fatalf("Sides: %v", err) + } + if sides.LeftUserID != "uid-left" { + t.Errorf("LeftUserID = %q, want uid-left", sides.LeftUserID) + } + if sides.RightUserID != "uid-right" { + t.Errorf("RightUserID = %q, want uid-right", sides.RightUserID) + } +} + func Test429RetryCapped(t *testing.T) { // Verify retries are bounded: after maxRetries, return an error. count := 0 diff --git a/internal/cmd/away.go b/internal/cmd/away.go new file mode 100644 index 0000000..9377069 --- /dev/null +++ b/internal/cmd/away.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/steipete/eightctl/internal/client" +) + +var awayCmd = &cobra.Command{ + Use: "away", + Short: "Away mode (vacation)", + Long: "Activate or deactivate away mode. When away, the pod stops heating/cooling.\nDefaults to the authenticated user's side. Use --both for both sides.", +} + +var awayOnCmd = &cobra.Command{ + Use: "on", + Short: "Activate away mode", + RunE: func(cmd *cobra.Command, args []string) error { return runAway(true) }, +} + +var awayOffCmd = &cobra.Command{ + Use: "off", + Short: "Deactivate away mode", + RunE: func(cmd *cobra.Command, args []string) error { return runAway(false) }, +} + +func runAway(on bool) error { + if err := requireAuthFields(); err != nil { + return err + } + cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret")) + ctx := context.Background() + both, _ := awayCmd.Flags().GetBool("both") + + if both { + sides, err := cl.Device().Sides(ctx) + if err != nil { + return fmt.Errorf("fetching device sides: %w", err) + } + for _, uid := range []string{sides.LeftUserID, sides.RightUserID} { + if uid == "" { + continue + } + if err := cl.SetAwayMode(ctx, uid, on); err != nil { + return fmt.Errorf("setting away for %s: %w", uid, err) + } + } + } else { + if err := cl.SetAwayMode(ctx, "", on); err != nil { + return err + } + } + + action := "activated" + if !on { + action = "deactivated" + } + scope := "your side" + if both { + scope = "both sides" + } + if !viper.GetBool("quiet") { + fmt.Printf("away mode %s (%s)\n", action, scope) + } + return nil +} + +func init() { + awayCmd.PersistentFlags().Bool("both", false, "Apply to both sides of the pod") + awayCmd.AddCommand(awayOnCmd) + awayCmd.AddCommand(awayOffCmd) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 16521da..43b07ca 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -58,6 +58,7 @@ func init() { rootCmd.AddCommand(onCmd) rootCmd.AddCommand(offCmd) + rootCmd.AddCommand(awayCmd) rootCmd.AddCommand(tempCmd) rootCmd.AddCommand(statusCmd) rootCmd.AddCommand(tracksCmd)