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
56 changes: 40 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# withings-export-cli

Export health data from [Withings](https://www.withings.com) scales, watches, and trackers. A command-line tool to back up measurements, sleep summaries, activity, and workouts — all from your terminal, with CSV or JSON output.
Export health data from [Withings](https://www.withings.com) scales, watches, and trackers. A command-line tool to back up measurements, sleep summaries, activity, and workouts — all from your terminal, with [fitdown](https://github.com/datavis-tech/fitdown)-style markdown by default and JSON or CSV on demand.

[![Latest Release](https://img.shields.io/github/v/release/quantcli/withings-export-cli)](https://github.com/quantcli/withings-export-cli/releases/latest)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Expand All @@ -14,7 +14,7 @@ Export health data from [Withings](https://www.withings.com) scales, watches, an
- **Activity** — daily steps, distance, elevation, calories, HR zones
- **Workouts** — runs, walks, bikes, swims, etc. with duration, HR, distance, and more
- **Date filtering** — relative (`30d`, `4w`, `6m`, `1y`) or absolute (`2026-01-01`)
- **CSV by default, JSON with `--json`** — pipe into your tool of choice
- **Fitdown-style markdown by default**, plus `--format json` and `--format csv` — pipe into your tool of choice
- **Multi-platform** — pre-built binaries for macOS (Intel + Apple Silicon), Linux, and Windows

## Quick Start
Expand Down Expand Up @@ -107,54 +107,78 @@ withings-export auth refresh # Force refresh and print status
### Measurements (scales, BP monitors, ECG)

```sh
withings-export measurements # last 30 days, CSV to stdout
withings-export measurements --since 1y # last year
withings-export measurements # last 30 days, markdown
withings-export measurements --since 1y # last year
withings-export measurements --since 2026-01-01
withings-export measurements --types 1,6,76 # only weight, body fat %, muscle mass
withings-export measurements --json # JSON instead of CSV
withings-export measurements --types 1,6,76 # only weight, body fat %, muscle mass
withings-export measurements --format json # JSON for scripting
withings-export measurements --format csv # CSV for spreadsheets
```

Common measure type codes: `1`=weight (kg), `6`=body fat %, `8`=fat mass (kg), `9`=diastolic BP, `10`=systolic BP, `11`=heart pulse, `54`=SpO₂ %, `76`=muscle mass, `77`=hydration, `88`=bone mass.

### Sleep

```sh
withings-export sleep # last 30 nights, CSV
withings-export sleep # last 30 nights, markdown
withings-export sleep --since 6m # last 6 months
withings-export sleep --json
withings-export sleep --derive # polyfill nights with no Withings summary
# using intraday HR samples (Apple Watch via HealthKit)
withings-export sleep --format json
```

Includes total sleep time, stages (light/deep/REM), sleep score, heart rate, respiratory rate, snoring episodes, and apnea-hypopnea index (if supported by your device).

### Activity

```sh
withings-export activity # last 30 days, CSV
withings-export activity # last 30 days, markdown
withings-export activity --since 1y
withings-export activity --json
withings-export activity --format json
```

Daily steps, distance, elevation, calories, time in HR zones.

### Workouts

```sh
withings-export workouts # last 90 days, CSV
withings-export workouts # last 90 days, markdown
withings-export workouts --since 6m
withings-export workouts --json
withings-export workouts --format json
```

Per-workout category (run/walk/bike/etc.), duration, calories, HR, distance, elevation.

## Output Format
### Intraday

**CSV (default):** header row + one row per data point. Suitable for spreadsheets, Grafana, pandas, etc.
```sh
withings-export intraday # last 24h, minute-level samples
withings-export intraday --since 7d
withings-export intraday --format csv
```

Per-minute heart rate, HRV (rmssd, sdnn1), SpO₂, steps, and distance — typically populated by an Apple Watch via the HealthKit bridge or by a native Withings tracker.

## Output Formats

**Markdown (default):** [fitdown](https://github.com/datavis-tech/fitdown)-style plain-text blocks with a date heading per record. Human-readable and easy to skim:

```
Workout 2026-04-23

lift_weights
08:58 → 10:12 (73 min)
482 cal
HR avg 76, 50-127
```

**JSON (`--format json`):** pretty-printed JSON array. Good for `jq` or custom scripts.

**JSON (`--json`):** pretty-printed JSON array. Good for `jq` or custom scripts.
**CSV (`--format csv`):** header row + one row per data point. Suitable for spreadsheets, Grafana, pandas, etc.

Example — average weight over the last year:
```sh
withings-export measurements --since 1y --types 1 --json \
withings-export measurements --since 1y --types 1 --format json \
| jq '[.[] | .value] | add / length'
```

Expand Down
47 changes: 41 additions & 6 deletions cmd/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"encoding/csv"
"fmt"
"net/url"
"os"
"sort"
Expand Down Expand Up @@ -40,8 +41,8 @@ type activityResponse struct {
}

var (
activityJSONFlag bool
activitySinceFlag string
activityFormatFlag string
activitySinceFlag string
)

var activityCmd = &cobra.Command{
Expand Down Expand Up @@ -78,10 +79,18 @@ var activityCmd = &cobra.Command{

sort.Slice(all, func(i, j int) bool { return all[i].Date < all[j].Date })

if activityJSONFlag {
format, err := validateFormat(activityFormatFlag)
if err != nil {
return err
}
switch format {
case "json":
return printJSON(all)
case "csv":
return writeActivityCSV(all)
default:
return writeActivityMarkdown(all)
}
return writeActivityCSV(all)
},
}

Expand Down Expand Up @@ -126,9 +135,35 @@ func writeActivityCSV(days []activityDay) error {
return nil
}

// writeActivityMarkdown emits one fitdown-style block per day. Empty/zero
// fields are dropped to keep output tight.
func writeActivityMarkdown(days []activityDay) error {
for _, d := range days {
fmt.Fprintf(os.Stdout, "Activity %s\n\n", d.Date)
if d.Steps > 0 || d.Distance > 0 {
fmt.Fprintf(os.Stdout, "%d steps, %.2f km\n", d.Steps, d.Distance/1000)
}
if d.Calories > 0 || d.TotalCalories > 0 {
fmt.Fprintf(os.Stdout, "%s cal active, %s total\n", fmtRound(d.Calories), fmtRound(d.TotalCalories))
}
if d.HRAvg > 0 {
fmt.Fprintf(os.Stdout, "HR avg %s, %s-%s\n", fmtRound(d.HRAvg), fmtRound(d.HRMin), fmtRound(d.HRMax))
}
if d.Active > 0 {
fmt.Fprintf(os.Stdout, "Active %s — %s soft, %s moderate, %s intense\n",
fmtDur(d.Active), fmtDur(d.Soft), fmtDur(d.Moderate), fmtDur(d.Intense))
}
if d.Elevation > 0 {
fmt.Fprintf(os.Stdout, "%s m elevation\n", fmtRound(d.Elevation))
}
fmt.Fprintln(os.Stdout)
}
return nil
}

func init() {
activityCmd.Flags().StringVar(&activitySinceFlag, "since", "",
"Filter on or after date (e.g. 2026-01-01, 30d, 4w, 6m, 1y; default 30d)")
activityCmd.Flags().BoolVar(&activityJSONFlag, "json", false,
"Output as JSON instead of CSV")
activityCmd.Flags().StringVar(&activityFormatFlag, "format", "markdown",
"Output format: markdown (default, fitdown-style), json, or csv")
}
63 changes: 57 additions & 6 deletions cmd/intraday.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"encoding/csv"
"fmt"
"net/url"
"os"
"sort"
Expand Down Expand Up @@ -51,8 +52,8 @@ type intradayResponse struct {
}

var (
intradayJSONFlag bool
intradaySinceFlag string
intradayFormatFlag string
intradaySinceFlag string
)

var intradayCmd = &cobra.Command{
Expand Down Expand Up @@ -118,10 +119,18 @@ Default window is the last 24h — intraday is dense; wider ranges are slow.`,

sort.Slice(all, func(i, j int) bool { return all[i].Timestamp < all[j].Timestamp })

if intradayJSONFlag {
format, err := validateFormat(intradayFormatFlag)
if err != nil {
return err
}
switch format {
case "json":
return printJSON(all)
case "csv":
return writeIntradayCSV(all)
default:
return writeIntradayMarkdown(all)
}
return writeIntradayCSV(all)
},
}

Expand Down Expand Up @@ -161,9 +170,51 @@ func writeIntradayCSV(samples []intradaySample) error {
return nil
}

// writeIntradayMarkdown emits one fitdown-style "Intraday DATE" block per
// local day, with one line per sample carrying only the fields that have
// values. Suppressing zero fields keeps the firehose readable.
func writeIntradayMarkdown(samples []intradaySample) error {
var lastDate string
for _, s := range samples {
ts := time.Unix(s.Timestamp, 0).Local()
date := ts.Format("2006-01-02")
if date != lastDate {
if lastDate != "" {
fmt.Fprintln(os.Stdout)
}
fmt.Fprintf(os.Stdout, "Intraday %s\n\n", date)
lastDate = date
}
fmt.Fprint(os.Stdout, ts.Format("15:04:05"))
if s.HeartRate > 0 {
fmt.Fprintf(os.Stdout, " HR %d", s.HeartRate)
}
if s.RMSSD > 0 {
fmt.Fprintf(os.Stdout, " rmssd %s", fmtNum(s.RMSSD))
}
if s.SDNN1 > 0 {
fmt.Fprintf(os.Stdout, " sdnn1 %s", fmtNum(s.SDNN1))
}
if s.SpO2 > 0 {
fmt.Fprintf(os.Stdout, " spo2 %s", fmtNum(s.SpO2))
}
if s.Steps > 0 {
fmt.Fprintf(os.Stdout, " %d steps", s.Steps)
}
if s.Distance > 0 {
fmt.Fprintf(os.Stdout, " %sm", fmtNum(s.Distance))
}
if s.Calories > 0 {
fmt.Fprintf(os.Stdout, " %s cal", fmtNum(s.Calories))
}
fmt.Fprintln(os.Stdout)
}
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")
intradayCmd.Flags().StringVar(&intradayFormatFlag, "format", "markdown",
"Output format: markdown (default, fitdown-style), json, or csv")
}
68 changes: 61 additions & 7 deletions cmd/measurements.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ type measurementRow struct {
}

var (
measurementsJSONFlag bool
measurementsSinceFlag string
measurementsTypesFlag string
measurementsFormatFlag string
measurementsSinceFlag string
measurementsTypesFlag string
)

var measurementsCmd = &cobra.Command{
Expand Down Expand Up @@ -126,10 +126,18 @@ var measurementsCmd = &cobra.Command{

sort.Slice(rows, func(i, j int) bool { return rows[i].Date.Before(rows[j].Date) })

if measurementsJSONFlag {
format, err := validateFormat(measurementsFormatFlag)
if err != nil {
return err
}
switch format {
case "json":
return printJSON(rows)
case "csv":
return writeMeasurementsCSV(rows)
default:
return writeMeasurementsMarkdown(rows)
}
return writeMeasurementsCSV(rows)
},
}

Expand All @@ -154,11 +162,57 @@ func writeMeasurementsCSV(rows []measurementRow) error {
return nil
}

// fmtMeasurement renders a measurement value with up to 3 decimal places,
// stripping float-precision noise (e.g. 107.35000000000001 → 107.35).
func fmtMeasurement(v float64) string {
s := strconv.FormatFloat(v, 'f', 3, 64)
// trim trailing zeros and trailing dot
for len(s) > 0 && s[len(s)-1] == '0' {
s = s[:len(s)-1]
}
if len(s) > 0 && s[len(s)-1] == '.' {
s = s[:len(s)-1]
}
return s
}

// writeMeasurementsMarkdown emits one fitdown-style block per measurement
// group (a single weigh-in event), with one line per measure type.
func writeMeasurementsMarkdown(rows []measurementRow) error {
type group struct {
date time.Time
rows []measurementRow
}
groups := map[int64]*group{}
var order []int64
for _, r := range rows {
g, ok := groups[r.GrpID]
if !ok {
g = &group{date: r.Date}
groups[r.GrpID] = g
order = append(order, r.GrpID)
}
g.rows = append(g.rows, r)
}
sort.Slice(order, func(i, j int) bool {
return groups[order[i]].date.Before(groups[order[j]].date)
})
for _, id := range order {
g := groups[id]
fmt.Fprintf(os.Stdout, "Measurements %s\n\n", g.date.Format("2006-01-02 15:04"))
for _, r := range g.rows {
fmt.Fprintf(os.Stdout, "%s %s\n", r.Type, fmtMeasurement(r.Value))
}
fmt.Fprintln(os.Stdout)
}
return nil
}

func init() {
measurementsCmd.Flags().StringVar(&measurementsSinceFlag, "since", "",
"Filter on or after date (e.g. 2026-01-01, 30d, 4w, 6m, 1y; default 30d)")
measurementsCmd.Flags().BoolVar(&measurementsJSONFlag, "json", false,
"Output as JSON instead of CSV")
measurementsCmd.Flags().StringVar(&measurementsFormatFlag, "format", "markdown",
"Output format: markdown (default, fitdown-style), json, or csv")
measurementsCmd.Flags().StringVar(&measurementsTypesFlag, "types", "",
"Comma-separated measure type codes to include (e.g. 1,6,76); default is all")
}
Loading
Loading