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.