From ad38f698bbbf2869add4e3985a9e9b91f94c169e Mon Sep 17 00:00:00 2001 From: Omar Shahine Date: Thu, 16 Apr 2026 04:09:27 +0000 Subject: [PATCH 1/3] fix: update API calls for current Eight Sleep endpoints - metrics summary/aggregate: add required metrics=all param (#18) - metrics trends: read --from/--to from cmd flags, add tz param (#20) - device owner: fallback to device info when /owner returns 404 (#19) - schedule list: add specialization param, fallback to household endpoint (#17) Closes #17, #18, #19, #20 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/client/device.go | 18 ++++++++++++++++-- internal/client/metrics.go | 18 ++++++++++++++---- internal/client/schedules.go | 15 +++++++++++++-- internal/cmd/metrics.go | 12 +++++------- 4 files changed, 48 insertions(+), 15 deletions(-) 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/metrics.go b/internal/client/metrics.go index 0b114ad..a0e22b2 100644 --- a/internal/client/metrics.go +++ b/internal/client/metrics.go @@ -11,13 +11,20 @@ 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) + } + if tz != "" { + q.Set("tz", tz) + } q.Set("include-main", "false") q.Set("include-all-sessions", "true") q.Set("model-version", "v2") @@ -37,8 +44,10 @@ func (m *MetricsActions) Summary(ctx context.Context, out any) error { if err := m.c.requireUser(ctx); err != nil { return err } + q := url.Values{} + q.Set("metrics", "all") path := fmt.Sprintf("/users/%s/metrics/summary", m.c.UserID) - return m.c.do(ctx, http.MethodGet, path, nil, nil, out) + return m.c.do(ctx, http.MethodGet, path, q, nil, out) } func (m *MetricsActions) Aggregate(ctx context.Context, out any) error { @@ -47,6 +56,7 @@ func (m *MetricsActions) Aggregate(ctx context.Context, out any) error { } q := url.Values{} q.Set("v2", "true") + q.Set("metrics", "all") path := fmt.Sprintf("/users/%s/metrics/aggregate", m.c.UserID) return m.c.do(ctx, http.MethodGet, path, q, nil, out) } diff --git a/internal/client/schedules.go b/internal/client/schedules.go index 4d81eaa..c2dd99e 100644 --- a/internal/client/schedules.go +++ b/internal/client/schedules.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "net/url" ) // TemperatureSchedule represents server-side temperature schedules. @@ -19,14 +20,24 @@ func (c *Client) ListSchedules(ctx context.Context) ([]TemperatureSchedule, erro if err := c.requireUser(ctx); err != nil { return nil, err } + q := url.Values{} + q.Set("specialization", "all") 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 { + if err := c.do(ctx, http.MethodGet, path, q, nil, &res); err == nil { + return res.Schedules, nil + } + // Fallback: some accounts reject /temperature/schedules; try household endpoint. + hpath := fmt.Sprintf("/household/users/%s/schedule", c.UserID) + var hres struct { + Schedules []TemperatureSchedule `json:"schedules"` + } + if err := c.do(ctx, http.MethodGet, hpath, nil, nil, &hres); err != nil { return nil, err } - return res.Schedules, nil + return hres.Schedules, nil } func (c *Client) CreateSchedule(ctx context.Context, s TemperatureSchedule) (*TemperatureSchedule, error) { diff --git a/internal/cmd/metrics.go b/internal/cmd/metrics.go index fdb9255..e725ca3 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 { @@ -78,10 +79,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) } From 82fa5b6ebe8c8de3cd35200cf5b05d3ace68b65f Mon Sep 17 00:00:00 2001 From: Lobster Date: Wed, 15 Apr 2026 21:29:28 -0700 Subject: [PATCH 2/3] fix: retire dead endpoints, resolve trends tz, retarget schedule Verification against live API (and cross-check with lukas-clarke/eight_sleep reference implementation) showed that the previous fixes added query params to endpoints that no longer exist rather than reaching working paths: - metrics summary/aggregate: /users/:id/metrics/{summary,aggregate} return 404 "Cannot GET" regardless of params. The modern API has no equivalent; all sleep metrics flow through /users/:id/trends. Remove the commands. - schedule CRUD: /users/:id/temperature/schedules and the household fallback both 404. Eight Sleep retired the routines/schedules API; the Autopilot schedule now lives as the `smart` subfield of app-api.8slp.net/v1/users/:id/temperature. Retarget `schedule list` to that endpoint and remove create/update/delete/next. - metrics trends: endpoint is correct and --from/--to flag plumbing works, but the default --timezone "local" was sent verbatim as tz=local, which the API rejects (wants an IANA zone). Resolve "local" to time.Local before querying. - device owner: fallback to /devices/:id ownerId already works; unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/client/metrics.go | 38 +++---- internal/client/schedules.go | 70 ++----------- internal/cmd/metrics.go | 26 +---- internal/cmd/schedule.go | 189 +---------------------------------- 4 files changed, 29 insertions(+), 294 deletions(-) diff --git a/internal/client/metrics.go b/internal/client/metrics.go index a0e22b2..5d601e9 100644 --- a/internal/client/metrics.go +++ b/internal/client/metrics.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/url" + "time" ) type MetricsActions struct{ c *Client } @@ -22,9 +23,7 @@ func (m *MetricsActions) Trends(ctx context.Context, from, to, tz string, out an if to != "" { q.Set("to", to) } - if tz != "" { - q.Set("tz", tz) - } + q.Set("tz", resolveTZ(tz)) q.Set("include-main", "false") q.Set("include-all-sessions", "true") q.Set("model-version", "v2") @@ -40,27 +39,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 - } - q := url.Values{} - q.Set("metrics", "all") - path := fmt.Sprintf("/users/%s/metrics/summary", m.c.UserID) - return m.c.do(ctx, http.MethodGet, path, q, 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") - q.Set("metrics", "all") - 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 @@ -68,3 +46,15 @@ func (m *MetricsActions) Insights(ctx context.Context, out any) error { path := fmt.Sprintf("/users/%s/insights", m.c.UserID) return m.c.do(ctx, http.MethodGet, path, nil, nil, out) } + +// resolveTZ maps "" or "local" to the system's IANA zone name. The Eight +// Sleep API rejects literal "local" and requires a real zone. +func resolveTZ(tz string) string { + if tz == "" || tz == "local" { + if name := time.Local.String(); name != "" && name != "Local" { + return name + } + return "UTC" + } + return tz +} diff --git a/internal/client/schedules.go b/internal/client/schedules.go index c2dd99e..76bc1f4 100644 --- a/internal/client/schedules.go +++ b/internal/client/schedules.go @@ -4,74 +4,22 @@ import ( "context" "fmt" "net/http" - "net/url" ) -// 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 - } - q := url.Values{} - q.Set("specialization", "all") - path := fmt.Sprintf("/users/%s/temperature/schedules", c.UserID) - var res struct { - Schedules []TemperatureSchedule `json:"schedules"` - } - if err := c.do(ctx, http.MethodGet, path, q, nil, &res); err == nil { - return res.Schedules, nil - } - // Fallback: some accounts reject /temperature/schedules; try household endpoint. - hpath := fmt.Sprintf("/household/users/%s/schedule", c.UserID) - var hres struct { - Schedules []TemperatureSchedule `json:"schedules"` - } - if err := c.do(ctx, http.MethodGet, hpath, nil, nil, &hres); err != nil { - return nil, err - } - return hres.Schedules, nil -} - -func (c *Client) CreateSchedule(ctx context.Context, s TemperatureSchedule) (*TemperatureSchedule, error) { +// GetSmartSchedule returns the Autopilot (smart) schedule for the current +// user. Eight Sleep retired the routines/temperature-schedules CRUD API; +// the current app surfaces schedule data as the `smart` subfield of +// GET app-api.8slp.net/v1/users/:id/temperature. +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", c.UserID) + 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.MethodPost, path, nil, s, &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) UpdateSchedule(ctx context.Context, id string, patch map[string]any) (*TemperatureSchedule, error) { - if err := c.requireUser(ctx); err != nil { - return nil, err - } - path := fmt.Sprintf("/users/%s/temperature/schedules/%s", c.UserID, id) - var res struct { - Schedule TemperatureSchedule `json:"schedule"` - } - if err := c.do(ctx, http.MethodPatch, path, nil, patch, &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 - } - 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 e725ca3..43d39ff 100644 --- a/internal/cmd/metrics.go +++ b/internal/cmd/metrics.go @@ -40,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 @@ -81,5 +57,5 @@ func init() { metricsTrendsCmd.Flags().String("to", "", "to date YYYY-MM-DD") metricsIntervalsCmd.Flags().String("id", "", "session 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..a2ce6d2 100644 --- a/internal/cmd/schedule.go +++ b/internal/cmd/schedule.go @@ -2,9 +2,6 @@ package cmd import ( "context" - "fmt" - "sort" - "time" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -15,201 +12,25 @@ 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", + Short: "Show the Autopilot schedule for the current user", 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", - 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 { 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) } From 2e8f44cf521fd6f886d74e31ad4a72fa39212a4c Mon Sep 17 00:00:00 2001 From: Lobster Date: Wed, 15 Apr 2026 21:34:02 -0700 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20self-review=20=E2=80=94=20?= =?UTF-8?q?share=20tz=20resolver,=20surface=20silent=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move resolveTZ to internal/client/eightsleep.go so GetSleepDay also normalizes tz. Fixes the same `tz=local`/`tz=Local` rejection in `sleep day` and `sleep range` that the prior commit only fixed for `metrics trends`. CLI call sites in cmd/sleep.go and cmd/sleep_range.go now pass the raw timezone through. - When resolveTZ falls back to UTC (system zoneinfo unavailable), emit a log.Warn so users see off-by-hours data flagged instead of silently trusting it. - GetSmartSchedule returns ErrNoSmartSchedule when the server omits or nulls the `smart` field, so an unconfigured Autopilot surfaces as a clear CLI message instead of an empty-row success. - Trim GetSmartSchedule doc comment to a single line stating the invariant, not PR history. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/client/eightsleep.go | 19 ++++++++++++++++++- internal/client/metrics.go | 13 ------------- internal/client/schedules.go | 14 ++++++++++---- internal/cmd/schedule.go | 6 ++++++ internal/cmd/sleep.go | 6 +----- internal/cmd/sleep_range.go | 5 +---- 6 files changed, 36 insertions(+), 27 deletions(-) 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 5d601e9..36e5584 100644 --- a/internal/client/metrics.go +++ b/internal/client/metrics.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "net/url" - "time" ) type MetricsActions struct{ c *Client } @@ -46,15 +45,3 @@ func (m *MetricsActions) Insights(ctx context.Context, out any) error { path := fmt.Sprintf("/users/%s/insights", m.c.UserID) return m.c.do(ctx, http.MethodGet, path, nil, nil, out) } - -// resolveTZ maps "" or "local" to the system's IANA zone name. The Eight -// Sleep API rejects literal "local" and requires a real zone. -func resolveTZ(tz string) string { - if tz == "" || tz == "local" { - if name := time.Local.String(); name != "" && name != "Local" { - return name - } - return "UTC" - } - return tz -} diff --git a/internal/client/schedules.go b/internal/client/schedules.go index 76bc1f4..c206e6a 100644 --- a/internal/client/schedules.go +++ b/internal/client/schedules.go @@ -2,14 +2,17 @@ package client import ( "context" + "errors" "fmt" "net/http" ) -// GetSmartSchedule returns the Autopilot (smart) schedule for the current -// user. Eight Sleep retired the routines/temperature-schedules CRUD API; -// the current app surfaces schedule data as the `smart` subfield of -// GET app-api.8slp.net/v1/users/:id/temperature. +// 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") + +// 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 @@ -21,5 +24,8 @@ func (c *Client) GetSmartSchedule(ctx context.Context) (map[string]any, error) { if err := c.doURL(ctx, http.MethodGet, u, nil, &res); err != nil { return nil, err } + if res.Smart == nil { + return nil, ErrNoSmartSchedule + } return res.Smart, nil } diff --git a/internal/cmd/schedule.go b/internal/cmd/schedule.go index a2ce6d2..76df3a3 100644 --- a/internal/cmd/schedule.go +++ b/internal/cmd/schedule.go @@ -2,6 +2,8 @@ package cmd import ( "context" + "errors" + "fmt" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -25,6 +27,10 @@ var scheduleListCmd = &cobra.Command{ cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret")) 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 } return output.Print(output.Format(viper.GetString("output")), []string{"smart"}, []map[string]any{{"smart": smart}}) 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)