From 05aea57ffefcede64da157e09635c419c1d4dac3 Mon Sep 17 00:00:00 2001 From: DTTerastar Date: Mon, 20 Apr 2026 19:13:30 -0400 Subject: [PATCH] Add intraday export; simplify OAuth scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `withings-export intraday` for minute-level samples (HR, HRV rmssd/sdnn1, SpO2, steps, distance) from `/v2/measure?action= getintradayactivity`. Withings caps each request to a 24h window, so the command chunks automatically from `--since` to now. This is the real data stream for accounts whose only live source is an Apple Watch via HealthKit: Withings stores the raw samples but does not compute a sleep summary from them, which is why `sleep` returns empty for recent dates on such accounts. Also simplifies the OAuth scope: drops `user.sleepevents` (per Withings docs, that grant is webhook-only — Notify endpoints for bed-in/bed-out — and unrelated to sleep data retrieval, which is already covered by `user.activity`). Adds `user.info` so device listing works if we expose it later. Users will need to re-run `auth login` to consent to the adjusted scope set. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/intraday.go | 169 ++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + internal/auth/auth.go | 6 +- 3 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 cmd/intraday.go diff --git a/cmd/intraday.go b/cmd/intraday.go new file mode 100644 index 0000000..9f29411 --- /dev/null +++ b/cmd/intraday.go @@ -0,0 +1,169 @@ +package cmd + +import ( + "encoding/csv" + "net/url" + "os" + "sort" + "strconv" + "time" + + "github.com/quantcli/withings-export-cli/internal/client" + "github.com/spf13/cobra" +) + +// Withings /v2/measure action=getintradayactivity returns at most 24h per call. +const intradayWindow = 24 * time.Hour + +type intradaySample struct { + Timestamp int64 `json:"timestamp"` + Duration int `json:"duration"` + Steps int `json:"steps"` + Distance float64 `json:"distance"` + Elevation float64 `json:"elevation"` + Calories float64 `json:"calories"` + HeartRate int `json:"heart_rate"` + HRVQuality int `json:"hrv_quality"` + RMSSD float64 `json:"rmssd"` + SDNN1 float64 `json:"sdnn1"` + SpO2 float64 `json:"spo2_auto"` + Model string `json:"model"` + ModelID int `json:"model_id"` +} + +type intradayPayload struct { + Duration int `json:"duration"` + Steps int `json:"steps"` + Distance float64 `json:"distance"` + Elevation float64 `json:"elevation"` + Calories float64 `json:"calories"` + HeartRate int `json:"heart_rate"` + HRVQuality int `json:"hrv_quality"` + RMSSD float64 `json:"rmssd"` + SDNN1 float64 `json:"sdnn1"` + SpO2 float64 `json:"spo2_auto"` + Model string `json:"model"` + ModelID int `json:"model_id"` +} + +type intradayResponse struct { + Series map[string]intradayPayload `json:"series"` +} + +var ( + intradayJSONFlag bool + intradaySinceFlag string +) + +var intradayCmd = &cobra.Command{ + Use: "intraday", + Short: "Export minute-level samples (HR, HRV, SpO2, steps, distance) from Apple Watch/Withings trackers", + Long: `Export Withings intraday activity samples. + +Withings caps each API request at a 24h window, so the CLI chunks requests +automatically. Each sample carries the data fields the source device reports: +Apple Watch via HealthKit typically provides heart_rate, hrv_rmssd/sdnn1, +spo2_auto, steps and distance; native Withings trackers report steps and HR. + +Default window is the last 24h — intraday is dense; wider ranges are slow.`, + RunE: func(cmd *cobra.Command, args []string) error { + since, err := sinceOrDefault(intradaySinceFlag, 1) + if err != nil { + return err + } + + dataFields := "steps,elevation,calories,distance,duration,heart_rate," + + "hrv_quality,rmssd,sdnn1,spo2_auto" + + c := client.New() + var all []intradaySample + for chunkStart := since; chunkStart.Before(time.Now()); chunkStart = chunkStart.Add(intradayWindow) { + chunkEnd := chunkStart.Add(intradayWindow) + if chunkEnd.After(time.Now()) { + chunkEnd = time.Now() + } + + params := url.Values{} + params.Set("action", "getintradayactivity") + params.Set("startdate", strconv.FormatInt(chunkStart.Unix(), 10)) + params.Set("enddate", strconv.FormatInt(chunkEnd.Unix(), 10)) + params.Set("data_fields", dataFields) + + var resp intradayResponse + if err := c.Call("/v2/measure", params, &resp); err != nil { + return err + } + for tsStr, p := range resp.Series { + ts, err := strconv.ParseInt(tsStr, 10, 64) + if err != nil { + continue + } + all = append(all, intradaySample{ + Timestamp: ts, + Duration: p.Duration, + Steps: p.Steps, + Distance: p.Distance, + Elevation: p.Elevation, + Calories: p.Calories, + HeartRate: p.HeartRate, + HRVQuality: p.HRVQuality, + RMSSD: p.RMSSD, + SDNN1: p.SDNN1, + SpO2: p.SpO2, + Model: p.Model, + ModelID: p.ModelID, + }) + } + } + + sort.Slice(all, func(i, j int) bool { return all[i].Timestamp < all[j].Timestamp }) + + if intradayJSONFlag { + return printJSON(all) + } + return writeIntradayCSV(all) + }, +} + +func writeIntradayCSV(samples []intradaySample) error { + w := csv.NewWriter(os.Stdout) + defer w.Flush() + header := []string{ + "timestamp", "datetime_utc", "duration_sec", + "steps", "distance_m", "elevation_m", "calories", + "heart_rate", "hrv_rmssd", "hrv_sdnn1", "hrv_quality", "spo2_auto", + "model", "model_id", + } + if err := w.Write(header); err != nil { + return err + } + for _, s := range samples { + row := []string{ + strconv.FormatInt(s.Timestamp, 10), + time.Unix(s.Timestamp, 0).UTC().Format(time.RFC3339), + strconv.Itoa(s.Duration), + strconv.Itoa(s.Steps), + strconv.FormatFloat(s.Distance, 'f', -1, 64), + strconv.FormatFloat(s.Elevation, 'f', -1, 64), + strconv.FormatFloat(s.Calories, 'f', -1, 64), + strconv.Itoa(s.HeartRate), + strconv.FormatFloat(s.RMSSD, 'f', -1, 64), + strconv.FormatFloat(s.SDNN1, 'f', -1, 64), + strconv.Itoa(s.HRVQuality), + strconv.FormatFloat(s.SpO2, 'f', -1, 64), + s.Model, + strconv.Itoa(s.ModelID), + } + if err := w.Write(row); err != nil { + return err + } + } + return nil +} + +func init() { + intradayCmd.Flags().StringVar(&intradaySinceFlag, "since", "", + "Filter on or after date (e.g. 2026-04-15, 1d, 4w, 6m; default 1d)") + intradayCmd.Flags().BoolVar(&intradayJSONFlag, "json", false, + "Output as JSON instead of CSV") +} diff --git a/cmd/root.go b/cmd/root.go index 75e6909..c31bc3a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,4 +23,5 @@ func init() { rootCmd.AddCommand(sleepCmd) rootCmd.AddCommand(activityCmd) rootCmd.AddCommand(workoutsCmd) + rootCmd.AddCommand(intradayCmd) } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 384112c..60a0e3f 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -21,8 +21,10 @@ import ( const ( authURL = "https://account.withings.com/oauth2_user/authorize2" tokenURL = "https://wbsapi.withings.net/v2/oauth2" - // Scopes covered by the subcommands: measurements, activity, sleep, workouts. - scope = "user.metrics,user.activity,user.sleepevents" + // user.activity covers activity, intraday, workouts, and sleep endpoints. + // user.metrics covers measurements and heart-rate endpoints. + // user.info covers device listing. user.sleepevents is webhook-only and unused here. + scope = "user.info,user.metrics,user.activity" ) // TokenStore is persisted to ~/.config/withings-export/auth.json.