diff --git a/.gitignore b/.gitignore index aaadf73..929a9b5 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ +eightctl 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 55fad67..3efb678 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" @@ -18,12 +19,15 @@ import ( const ( defaultBaseURL = "https://client-api.8slp.net/v1" - authURL = "https://auth-api.8slp.net/v1/tokens" + appAPIBaseURL = "https://app-api.8slp.net/v1" // Extracted from the official Eight Sleep Android app v7.39.17 (public client creds) defaultClientID = "0894c7f33bb94800a03f1f4df13a4f38" defaultClientSecret = "f0954a3ed5763ba3d06834c73731a32f15f168f47d4f164751275def86db0c76" ) +// authURL is a var so tests can point it at a local server. +var authURL = "https://auth-api.8slp.net/v1/tokens" + // Client represents Eight Sleep API client. type Client struct { Email string @@ -116,19 +120,19 @@ func (c *Client) EnsureDeviceID(ctx context.Context) (string, error) { } func (c *Client) authTokenEndpoint(ctx context.Context) error { - payload := map[string]string{ - "grant_type": "password", - "username": c.Email, - "password": c.Password, - "client_id": "sleep-client", - "client_secret": "", - } - body, _ := json.Marshal(payload) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL, bytes.NewReader(body)) + form := url.Values{ + "grant_type": {"password"}, + "username": {c.Email}, + "password": {c.Password}, + "client_id": {c.ClientID}, + "client_secret": {c.ClientSecret}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL, + bytes.NewReader([]byte(form.Encode()))) if err != nil { return err } - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.HTTP.Do(req) if err != nil { @@ -257,6 +261,14 @@ func (c *Client) requireUser(ctx context.Context) error { } func (c *Client) do(ctx context.Context, method, path string, query url.Values, body any, out any) error { + u := c.BaseURL + path + if len(query) > 0 { + u += "?" + query.Encode() + } + return c.doURL(ctx, method, u, body, out) +} + +func (c *Client) doURL(ctx context.Context, method, u string, body any, out any) error { if err := c.ensureToken(ctx); err != nil { return err } @@ -268,10 +280,6 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, } 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 @@ -290,7 +298,7 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, defer resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests { time.Sleep(2 * time.Second) - return c.do(ctx, method, path, query, body, out) + return c.doURL(ctx, method, u, body, out) } if resp.StatusCode == http.StatusUnauthorized { c.token = "" @@ -298,14 +306,23 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, if err := c.ensureToken(ctx); err != nil { return err } - return c.do(ctx, method, path, query, body, out) + return c.doURL(ctx, method, u, body, out) } 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) + r := io.Reader(resp.Body) + if resp.Header.Get("Content-Encoding") == "gzip" { + gz, err := gzip.NewReader(resp.Body) + if err != nil { + return fmt.Errorf("gzip decode: %w", err) + } + defer gz.Close() + r = gz + } + return json.NewDecoder(r).Decode(out) } return nil } @@ -350,6 +367,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 a6204ac..f435568 100644 --- a/internal/client/eightsleep_test.go +++ b/internal/client/eightsleep_test.go @@ -1,7 +1,11 @@ package client import ( + "bytes" + "compress/gzip" "context" + "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -70,6 +74,133 @@ func TestRequireUserFilledAutomatically(t *testing.T) { } } +func TestAuthTokenEndpoint_FormEncoded(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Must be form-encoded, not JSON. + ct := r.Header.Get("Content-Type") + if ct != "application/x-www-form-urlencoded" { + t.Errorf("expected form-urlencoded, got %s", ct) + http.Error(w, "bad content type", http.StatusBadRequest) + return + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + // Verify correct client credentials are sent (not "sleep-client"). + if got := r.PostFormValue("client_id"); got != defaultClientID { + t.Errorf("client_id = %q, want %q", got, defaultClientID) + } + if got := r.PostFormValue("client_secret"); got != defaultClientSecret { + t.Errorf("client_secret = %q, want %q", got, defaultClientSecret) + } + if got := r.PostFormValue("grant_type"); got != "password" { + t.Errorf("grant_type = %q, want password", got) + } + if got := r.PostFormValue("username"); got != "test@example.com" { + t.Errorf("username = %q, want test@example.com", got) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "tok-123", + "expires_in": 3600, + "userId": "uid-abc", + }) + })) + defer srv.Close() + + old := authURL + authURL = srv.URL + defer func() { authURL = old }() + + c := New("test@example.com", "secret", "", "", "") + c.HTTP = srv.Client() + + if err := c.Authenticate(context.Background()); err != nil { + t.Fatalf("Authenticate: %v", err) + } + if c.token != "tok-123" { + t.Errorf("token = %q, want tok-123", c.token) + } + if c.UserID != "uid-abc" { + t.Errorf("UserID = %q, want uid-abc", c.UserID) + } +} + +func TestAuthTokenEndpoint_FallsBackToLegacy(t *testing.T) { + tokenCalled := false + legacyCalled := false + + mux := http.NewServeMux() + // Token endpoint fails + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + tokenCalled = true + http.Error(w, "nope", http.StatusBadRequest) + }) + // Legacy login succeeds + mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { + legacyCalled = true + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"session":{"token":"legacy-tok","userId":"uid-legacy","expirationDate":"2099-01-01T00:00:00Z"}}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + old := authURL + authURL = srv.URL + "/token" + defer func() { authURL = old }() + + c := New("test@example.com", "secret", "", "", "") + c.BaseURL = srv.URL + c.HTTP = srv.Client() + + if err := c.Authenticate(context.Background()); err != nil { + t.Fatalf("Authenticate: %v", err) + } + if !tokenCalled { + t.Error("token endpoint was not tried") + } + if !legacyCalled { + t.Error("legacy login was not tried after token failure") + } + if c.token != "legacy-tok" { + t.Errorf("token = %q, want legacy-tok", c.token) + } +} + +func TestGzipResponseDecoded(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-gz","currentDevice":{"id":"dev-gz"}}}`)) + }) + mux.HandleFunc("/users/uid-gz/temperature", func(w http.ResponseWriter, r *http.Request) { + // Respond with gzip-encoded body + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + gz.Write([]byte(`{"currentLevel":42,"currentState":{"type":"on"}}`)) + gz.Close() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Encoding", "gzip") + w.Write(buf.Bytes()) + }) + 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() + + st, err := c.GetStatus(context.Background()) + if err != nil { + t.Fatalf("GetStatus with gzip response: %v", err) + } + if st.CurrentLevel != 42 { + t.Errorf("CurrentLevel = %d, want 42", st.CurrentLevel) + } +} + func Test429Retry(t *testing.T) { count := 0 mux := http.NewServeMux() diff --git a/internal/cmd/away.go b/internal/cmd/away.go new file mode 100644 index 0000000..881804f --- /dev/null +++ b/internal/cmd/away.go @@ -0,0 +1,74 @@ +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" + } + 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)