Skip to content
Merged
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
22 changes: 22 additions & 0 deletions internal/client/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
59 changes: 46 additions & 13 deletions internal/client/eightsleep.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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"`
Expand Down
93 changes: 93 additions & 0 deletions internal/client/eightsleep_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions internal/cmd/away.go
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func init() {

rootCmd.AddCommand(onCmd)
rootCmd.AddCommand(offCmd)
rootCmd.AddCommand(awayCmd)
rootCmd.AddCommand(tempCmd)
rootCmd.AddCommand(statusCmd)
rootCmd.AddCommand(tracksCmd)
Expand Down
Loading