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
18 changes: 16 additions & 2 deletions internal/client/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,22 @@ func (d *DeviceActions) Owner(ctx context.Context) (any, error) {
}
path := fmt.Sprintf("/devices/%s/owner", id)
var res any
err = d.c.do(ctx, http.MethodGet, path, nil, nil, &res)
return res, err
if err := d.c.do(ctx, http.MethodGet, path, nil, nil, &res); err == nil {
return res, nil
}
// Fallback: extract ownerId from device info when /owner endpoint is unavailable.
info, err := d.Info(ctx)
if err != nil {
return nil, err
}
if m, ok := info.(map[string]any); ok {
if result, ok := m["result"].(map[string]any); ok {
if ownerID, ok := result["ownerId"].(string); ok {
return map[string]any{"ownerId": ownerID}, nil
}
}
}
return nil, fmt.Errorf("owner not found in device info")
}

func (d *DeviceActions) Warranty(ctx context.Context) (any, error) {
Expand Down
19 changes: 18 additions & 1 deletion internal/client/eightsleep.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ func (c *Client) GetSleepDay(ctx context.Context, date string, timezone string)
return nil, err
}
q := url.Values{}
q.Set("tz", timezone)
q.Set("tz", resolveTZ(timezone))
q.Set("from", date)
q.Set("to", date)
q.Set("include-main", "false")
Expand Down Expand Up @@ -434,6 +434,23 @@ type ReleaseFeature struct {
Body string `json:"body"`
}

// resolveTZ converts the CLI-convention zone "" or "local" to an IANA zone
// name. Eight Sleep's tz param rejects the literal strings "local" and
// "Local" (the latter is what time.Local.String() returns when the system
// has no zoneinfo). UTC is used as a last-resort fallback and logged so
// off-by-hours trend data is not presented as correct.
func resolveTZ(tz string) string {
if tz != "" && tz != "local" {
return tz
}
name := time.Local.String()
if name == "" || name == "Local" {
log.Warn("system timezone unresolved; defaulting to UTC. Pass --timezone <IANA> to override.")
return "UTC"
}
return name
}

func (c *Client) ReleaseFeatures(ctx context.Context) ([]ReleaseFeature, error) {
path := "/release/features"
var res struct {
Expand Down
29 changes: 8 additions & 21 deletions internal/client/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@ type MetricsActions struct{ c *Client }

func (c *Client) Metrics() *MetricsActions { return &MetricsActions{c: c} }

func (m *MetricsActions) Trends(ctx context.Context, from, to string, out any) error {
func (m *MetricsActions) Trends(ctx context.Context, from, to, tz string, out any) error {
if err := m.c.requireUser(ctx); err != nil {
return err
}
q := url.Values{}
q.Set("from", from)
q.Set("to", to)
if from != "" {
q.Set("from", from)
}
if to != "" {
q.Set("to", to)
}
q.Set("tz", resolveTZ(tz))
q.Set("include-main", "false")
q.Set("include-all-sessions", "true")
q.Set("model-version", "v2")
Expand All @@ -33,24 +38,6 @@ func (m *MetricsActions) Intervals(ctx context.Context, sessionID string, out an
return m.c.do(ctx, http.MethodGet, path, nil, nil, out)
}

func (m *MetricsActions) Summary(ctx context.Context, out any) error {
if err := m.c.requireUser(ctx); err != nil {
return err
}
path := fmt.Sprintf("/users/%s/metrics/summary", m.c.UserID)
return m.c.do(ctx, http.MethodGet, path, nil, nil, out)
}

func (m *MetricsActions) Aggregate(ctx context.Context, out any) error {
if err := m.c.requireUser(ctx); err != nil {
return err
}
q := url.Values{}
q.Set("v2", "true")
path := fmt.Sprintf("/users/%s/metrics/aggregate", m.c.UserID)
return m.c.do(ctx, http.MethodGet, path, q, nil, out)
}

func (m *MetricsActions) Insights(ctx context.Context, out any) error {
if err := m.c.requireUser(ctx); err != nil {
return err
Expand Down
61 changes: 13 additions & 48 deletions internal/client/schedules.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,65 +2,30 @@ package client

import (
"context"
"errors"
"fmt"
"net/http"
)

// TemperatureSchedule represents server-side temperature schedules.
type TemperatureSchedule struct {
ID string `json:"id"`
StartTime string `json:"startTime"`
Level int `json:"level"`
DaysOfWeek []int `json:"daysOfWeek"`
Enabled bool `json:"enabled"`
}

func (c *Client) ListSchedules(ctx context.Context) ([]TemperatureSchedule, error) {
if err := c.requireUser(ctx); err != nil {
return nil, err
}
path := fmt.Sprintf("/users/%s/temperature/schedules", c.UserID)
var res struct {
Schedules []TemperatureSchedule `json:"schedules"`
}
if err := c.do(ctx, http.MethodGet, path, nil, nil, &res); err != nil {
return nil, err
}
return res.Schedules, nil
}

func (c *Client) CreateSchedule(ctx context.Context, s TemperatureSchedule) (*TemperatureSchedule, error) {
if err := c.requireUser(ctx); err != nil {
return nil, err
}
path := fmt.Sprintf("/users/%s/temperature/schedules", c.UserID)
var res struct {
Schedule TemperatureSchedule `json:"schedule"`
}
if err := c.do(ctx, http.MethodPost, path, nil, s, &res); err != nil {
return nil, err
}
return &res.Schedule, nil
}
// ErrNoSmartSchedule is returned when the user has no Autopilot schedule
// configured (server omits or nulls the `smart` field).
var ErrNoSmartSchedule = errors.New("no Autopilot schedule configured")

func (c *Client) UpdateSchedule(ctx context.Context, id string, patch map[string]any) (*TemperatureSchedule, error) {
// GetSmartSchedule returns the `smart` subfield of the app-api temperature
// resource (the Autopilot schedule).
func (c *Client) GetSmartSchedule(ctx context.Context) (map[string]any, error) {
if err := c.requireUser(ctx); err != nil {
return nil, err
}
path := fmt.Sprintf("/users/%s/temperature/schedules/%s", c.UserID, id)
u := fmt.Sprintf("%s/users/%s/temperature", appAPIBaseURL, c.UserID)
var res struct {
Schedule TemperatureSchedule `json:"schedule"`
Smart map[string]any `json:"smart"`
}
if err := c.do(ctx, http.MethodPatch, path, nil, patch, &res); err != nil {
if err := c.doURL(ctx, http.MethodGet, u, nil, &res); err != nil {
return nil, err
}
return &res.Schedule, nil
}

func (c *Client) DeleteSchedule(ctx context.Context, id string) error {
if err := c.requireUser(ctx); err != nil {
return err
if res.Smart == nil {
return nil, ErrNoSmartSchedule
}
path := fmt.Sprintf("/users/%s/temperature/schedules/%s", c.UserID, id)
return c.do(ctx, http.MethodDelete, path, nil, nil, nil)
return res.Smart, nil
}
38 changes: 6 additions & 32 deletions internal/cmd/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ var metricsTrendsCmd = &cobra.Command{Use: "trends", RunE: func(cmd *cobra.Comma
if err := requireAuthFields(); err != nil {
return err
}
from := viper.GetString("from")
to := viper.GetString("to")
from, _ := cmd.Flags().GetString("from")
to, _ := cmd.Flags().GetString("to")
tz := viper.GetString("timezone")
cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret"))
var out any
if err := cl.Metrics().Trends(context.Background(), from, to, &out); err != nil {
if err := cl.Metrics().Trends(context.Background(), from, to, tz, &out); err != nil {
return err
}
return output.Print(output.Format(viper.GetString("output")), []string{"trends"}, []map[string]any{{"trends": out}})
Expand All @@ -30,7 +31,7 @@ var metricsIntervalsCmd = &cobra.Command{Use: "intervals", RunE: func(cmd *cobra
if err := requireAuthFields(); err != nil {
return err
}
id := viper.GetString("id")
id, _ := cmd.Flags().GetString("id")
cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret"))
var out any
if err := cl.Metrics().Intervals(context.Background(), id, &out); err != nil {
Expand All @@ -39,30 +40,6 @@ var metricsIntervalsCmd = &cobra.Command{Use: "intervals", RunE: func(cmd *cobra
return output.Print(output.Format(viper.GetString("output")), []string{"interval"}, []map[string]any{{"interval": out}})
}}

var metricsSummaryCmd = &cobra.Command{Use: "summary", RunE: func(cmd *cobra.Command, args []string) 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"))
var out any
if err := cl.Metrics().Summary(context.Background(), &out); err != nil {
return err
}
return output.Print(output.Format(viper.GetString("output")), []string{"summary"}, []map[string]any{{"summary": out}})
}}

var metricsAggregateCmd = &cobra.Command{Use: "aggregate", RunE: func(cmd *cobra.Command, args []string) 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"))
var out any
if err := cl.Metrics().Aggregate(context.Background(), &out); err != nil {
return err
}
return output.Print(output.Format(viper.GetString("output")), []string{"aggregate"}, []map[string]any{{"aggregate": out}})
}}

var metricsInsightsCmd = &cobra.Command{Use: "insights", RunE: func(cmd *cobra.Command, args []string) error {
if err := requireAuthFields(); err != nil {
return err
Expand All @@ -78,10 +55,7 @@ var metricsInsightsCmd = &cobra.Command{Use: "insights", RunE: func(cmd *cobra.C
func init() {
metricsTrendsCmd.Flags().String("from", "", "from date YYYY-MM-DD")
metricsTrendsCmd.Flags().String("to", "", "to date YYYY-MM-DD")
viper.BindPFlag("from", metricsTrendsCmd.Flags().Lookup("from"))
viper.BindPFlag("to", metricsTrendsCmd.Flags().Lookup("to"))
metricsIntervalsCmd.Flags().String("id", "", "session id")
viper.BindPFlag("id", metricsIntervalsCmd.Flags().Lookup("id"))

metricsCmd.AddCommand(metricsTrendsCmd, metricsIntervalsCmd, metricsSummaryCmd, metricsAggregateCmd, metricsInsightsCmd)
metricsCmd.AddCommand(metricsTrendsCmd, metricsIntervalsCmd, metricsInsightsCmd)
}
Loading
Loading