diff --git a/internal/client/device.go b/internal/client/device.go index df14940..087a08e 100644 --- a/internal/client/device.go +++ b/internal/client/device.go @@ -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) { diff --git a/internal/client/eightsleep.go b/internal/client/eightsleep.go index f1fcf24..8b7e9c0 100644 --- a/internal/client/eightsleep.go +++ b/internal/client/eightsleep.go @@ -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") @@ -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 to override.") + return "UTC" + } + return name +} + func (c *Client) ReleaseFeatures(ctx context.Context) ([]ReleaseFeature, error) { path := "/release/features" var res struct { diff --git a/internal/client/metrics.go b/internal/client/metrics.go index 0b114ad..36e5584 100644 --- a/internal/client/metrics.go +++ b/internal/client/metrics.go @@ -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") @@ -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 diff --git a/internal/client/schedules.go b/internal/client/schedules.go index 4d81eaa..c206e6a 100644 --- a/internal/client/schedules.go +++ b/internal/client/schedules.go @@ -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 } diff --git a/internal/cmd/metrics.go b/internal/cmd/metrics.go index fdb9255..43d39ff 100644 --- a/internal/cmd/metrics.go +++ b/internal/cmd/metrics.go @@ -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}}) @@ -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 { @@ -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 @@ -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) } diff --git a/internal/cmd/schedule.go b/internal/cmd/schedule.go index cb865f1..76df3a3 100644 --- a/internal/cmd/schedule.go +++ b/internal/cmd/schedule.go @@ -2,9 +2,8 @@ package cmd import ( "context" + "errors" "fmt" - "sort" - "time" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -15,201 +14,29 @@ import ( var scheduleCmd = &cobra.Command{ Use: "schedule", - Short: "Manage device temperature schedules (cloud)", -} - -var scheduleNextCmd = &cobra.Command{ - Use: "next", - Short: "Show next upcoming schedule events", - RunE: func(cmd *cobra.Command, args []string) error { - if err := requireAuthFields(); err != nil { - return err - } - tzName := viper.GetString("timezone") - loc := time.Local - if tzName != "" && tzName != "local" { - l, err := time.LoadLocation(tzName) - if err != nil { - return err - } - loc = l - } - now := time.Now().In(loc) - - cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret")) - scheds, err := cl.ListSchedules(context.Background()) - if err != nil { - return err - } - - rows := make([]map[string]any, 0, len(scheds)) - for _, s := range scheds { - next := nextOccurrence(now, s, loc) - rows = append(rows, map[string]any{ - "id": s.ID, - "start": s.StartTime, - "days": s.DaysOfWeek, - "level": s.Level, - "enabled": s.Enabled, - "next": next.Format(time.RFC3339), - }) - } - - sort.Slice(rows, func(i, j int) bool { return rows[i]["next"].(string) < rows[j]["next"].(string) }) - rows = output.FilterFields(rows, viper.GetStringSlice("fields")) - headers := []string{"id", "start", "days", "level", "enabled", "next"} - if len(viper.GetStringSlice("fields")) > 0 { - headers = viper.GetStringSlice("fields") - } - return output.Print(output.Format(viper.GetString("output")), headers, rows) - }, + Short: "Show the Autopilot (smart) schedule", } var scheduleListCmd = &cobra.Command{ Use: "list", - Short: "List schedules", - 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")) - scheds, err := cl.ListSchedules(context.Background()) - if err != nil { - return err - } - rows := make([]map[string]any, 0, len(scheds)) - for _, s := range scheds { - rows = append(rows, map[string]any{ - "id": s.ID, - "start": s.StartTime, - "level": s.Level, - "days": s.DaysOfWeek, - "enabled": s.Enabled, - }) - } - rows = output.FilterFields(rows, viper.GetStringSlice("fields")) - return output.Print(output.Format(viper.GetString("output")), []string{"id", "start", "level", "days", "enabled"}, rows) - }, -} - -var scheduleCreateCmd = &cobra.Command{ - Use: "create", - Short: "Create schedule", + Short: "Show the Autopilot schedule for the current user", RunE: func(cmd *cobra.Command, args []string) error { if err := requireAuthFields(); err != nil { return err } - start := viper.GetString("start") - if start == "" { - return fmt.Errorf("--start HH:MM required") - } - level := viper.GetInt("level") - days := viper.GetIntSlice("days") - if len(days) == 0 { - return fmt.Errorf("--days required") - } - enabled := !viper.GetBool("disabled") cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret")) - s := client.TemperatureSchedule{StartTime: start, Level: level, DaysOfWeek: days, Enabled: enabled} - res, err := cl.CreateSchedule(context.Background(), s) + smart, err := cl.GetSmartSchedule(context.Background()) if err != nil { + if errors.Is(err, client.ErrNoSmartSchedule) { + fmt.Fprintln(cmd.OutOrStdout(), "no Autopilot schedule configured for this user") + return nil + } return err } - fmt.Printf("created schedule %s\n", res.ID) - return nil - }, -} - -var scheduleUpdateCmd = &cobra.Command{ - Use: "update ", - Short: "Update schedule", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - if err := requireAuthFields(); err != nil { - return err - } - patch := map[string]any{} - if cmd.Flags().Changed("start") { - patch["startTime"] = viper.GetString("start") - } - if cmd.Flags().Changed("level") { - patch["level"] = viper.GetInt("level") - } - if cmd.Flags().Changed("days") { - patch["daysOfWeek"] = viper.GetIntSlice("days") - } - if cmd.Flags().Changed("enabled") { - patch["enabled"] = viper.GetBool("enabled") - } - if len(patch) == 0 { - return fmt.Errorf("no fields to update") - } - cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret")) - if _, err := cl.UpdateSchedule(context.Background(), args[0], patch); err != nil { - return err - } - fmt.Println("updated") - return nil - }, -} - -var scheduleDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete schedule", - Args: cobra.ExactArgs(1), - 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")) - if err := cl.DeleteSchedule(context.Background(), args[0]); err != nil { - return err - } - fmt.Println("deleted") - return nil + return output.Print(output.Format(viper.GetString("output")), []string{"smart"}, []map[string]any{{"smart": smart}}) }, } func init() { - scheduleCreateCmd.Flags().String("start", "", "HH:MM start time") - scheduleCreateCmd.Flags().Int("level", 0, "Temperature level -100..100") - scheduleCreateCmd.Flags().IntSlice("days", nil, "Comma-separated days 0=Sun..6=Sat") - scheduleCreateCmd.Flags().Bool("disabled", false, "Create disabled") - viper.BindPFlag("start", scheduleCreateCmd.Flags().Lookup("start")) - viper.BindPFlag("level", scheduleCreateCmd.Flags().Lookup("level")) - viper.BindPFlag("days", scheduleCreateCmd.Flags().Lookup("days")) - viper.BindPFlag("disabled", scheduleCreateCmd.Flags().Lookup("disabled")) - - scheduleUpdateCmd.Flags().String("start", "", "HH:MM start time") - scheduleUpdateCmd.Flags().Int("level", 0, "Temperature level -100..100") - scheduleUpdateCmd.Flags().IntSlice("days", nil, "Comma-separated days 0=Sun..6=Sat") - scheduleUpdateCmd.Flags().Bool("enabled", true, "Enable/disable schedule") - viper.BindPFlag("start", scheduleUpdateCmd.Flags().Lookup("start")) - viper.BindPFlag("level", scheduleUpdateCmd.Flags().Lookup("level")) - viper.BindPFlag("days", scheduleUpdateCmd.Flags().Lookup("days")) - viper.BindPFlag("enabled", scheduleUpdateCmd.Flags().Lookup("enabled")) - - scheduleCmd.AddCommand(scheduleListCmd, scheduleCreateCmd, scheduleUpdateCmd, scheduleDeleteCmd, scheduleNextCmd) -} - -func nextOccurrence(now time.Time, s client.TemperatureSchedule, loc *time.Location) time.Time { - hour, min, _ := time.Now().Clock() - if t, err := time.Parse("15:04", s.StartTime); err == nil { - hour, min, _ = t.Clock() - } - days := map[int]bool{} - for _, d := range s.DaysOfWeek { - days[d] = true - } - for i := 0; i < 14; i++ { - day := now.In(loc).AddDate(0, 0, i) - if len(days) > 0 && !days[int(day.Weekday())] { - continue - } - cand := time.Date(day.Year(), day.Month(), day.Day(), hour, min, 0, 0, loc) - if cand.After(now) { - return cand - } - } - return now + scheduleCmd.AddCommand(scheduleListCmd) } diff --git a/internal/cmd/sleep.go b/internal/cmd/sleep.go index 8a22ff1..b790974 100644 --- a/internal/cmd/sleep.go +++ b/internal/cmd/sleep.go @@ -28,12 +28,8 @@ var sleepDayCmd = &cobra.Command{ if date == "" { date = time.Now().Format("2006-01-02") } - tz := viper.GetString("timezone") - if tz == "local" { - tz = time.Local.String() - } cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret")) - day, err := cl.GetSleepDay(context.Background(), date, tz) + day, err := cl.GetSleepDay(context.Background(), date, viper.GetString("timezone")) if err != nil { return err } diff --git a/internal/cmd/sleep_range.go b/internal/cmd/sleep_range.go index d31f1ec..55bb029 100644 --- a/internal/cmd/sleep_range.go +++ b/internal/cmd/sleep_range.go @@ -36,11 +36,8 @@ var sleepRangeCmd = &cobra.Command{ if end.Before(start) { return fmt.Errorf("to must be >= from") } - tz := viper.GetString("timezone") - if tz == "local" { - tz = time.Local.String() - } cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret")) + tz := viper.GetString("timezone") rows := []map[string]any{} for d := start; !d.After(end); d = d.Add(24 * time.Hour) { day, err := cl.GetSleepDay(context.Background(), d.Format(layout), tz)