Skip to content
Merged
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
196 changes: 194 additions & 2 deletions cmd/sleep.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ type sleepSeries struct {
EndDate int64 `json:"enddate"`
Date string `json:"date"`
Data json.RawMessage `json:"data"`
// Source is synthetic (not from API): "summary" for getsummary rows,
// "derived" for rows polyfilled from intraday samples via --derive.
Source string `json:"source"`
}

type sleepResponse struct {
Expand All @@ -30,8 +33,9 @@ type sleepResponse struct {
}

var (
sleepJSONFlag bool
sleepSinceFlag string
sleepJSONFlag bool
sleepSinceFlag string
sleepDeriveFlag bool
)

var sleepCmd = &cobra.Command{
Expand Down Expand Up @@ -69,6 +73,36 @@ var sleepCmd = &cobra.Command{
params.Set("offset", strconv.Itoa(resp.Offset))
}

haveDate := make(map[string]bool, len(all))
for i := range all {
all[i].Source = "summary"
haveDate[all[i].Date] = true
}

if sleepDeriveFlag {
today := time.Now()
first := true
for d := since; !d.After(today); d = d.AddDate(0, 0, 1) {
dateStr := d.Format("2006-01-02")
if haveDate[dateStr] {
continue
}
// Throttle — Withings rate-limits aggressive callers (status 601).
// 250ms between calls keeps a wide window-derive under the cap.
if !first {
time.Sleep(250 * time.Millisecond)
}
first = false
derived, err := deriveSleep(c, d)
if err != nil {
return err
}
if derived != nil {
all = append(all, *derived)
}
}
}

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

if sleepJSONFlag {
Expand All @@ -78,6 +112,160 @@ var sleepCmd = &cobra.Command{
},
}

// deriveSleep polyfills a sleep start/end for a night that has no getsummary
// record, by finding the longest contiguous "quiet" run of intraday samples.
//
// Window: prior-day 18:00 local → current-day 12:00 local (18h — fits in one
// 24h getintradayactivity call). A sample is "quiet" when heart_rate is
// present, ≤ 80 bpm, and steps == 0. Runs tolerate gaps ≤ 60 min between
// consecutive quiet samples (watch off for charging). The longest qualifying
// run ≥ 3h becomes the derived sleep session. Returns (nil, nil) when no
// window qualifies or intraday has no samples.
func deriveSleep(c *client.Client, date time.Time) (*sleepSeries, error) {
loc := time.Local
day := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc)
winStart := day.AddDate(0, 0, -1).Add(18 * time.Hour)
winEnd := day.Add(12 * time.Hour)

params := url.Values{}
params.Set("action", "getintradayactivity")
params.Set("startdate", strconv.FormatInt(winStart.Unix(), 10))
params.Set("enddate", strconv.FormatInt(winEnd.Unix(), 10))
params.Set("data_fields", "steps,heart_rate,duration")

var resp intradayResponse
if err := c.Call("/v2/measure", params, &resp); err != nil {
return nil, err
}

type sample struct {
ts int64
hr int
steps int
}
samples := make([]sample, 0, len(resp.Series))
for tsStr, p := range resp.Series {
ts, err := strconv.ParseInt(tsStr, 10, 64)
if err != nil {
continue
}
samples = append(samples, sample{ts: ts, hr: p.HeartRate, steps: p.Steps})
}
sort.Slice(samples, func(i, j int) bool { return samples[i].ts < samples[j].ts })
if len(samples) == 0 {
return nil, nil
}

// A sample "breaks" a sleep run when it shows real wakefulness: elevated
// heart rate, or a meaningful burst of steps. Small step samples (bathroom
// trips, rolling over) do not break the run — they're just HealthKit's way
// of reporting brief motion without an accompanying HR reading.
const hrBreak = 90 // bpm above this = awake/active
const stepsBreak = 30

const maxGap int64 = 90 * 60 // 90 min — tolerate watch-off-for-charging
const minDur int64 = 3 * 3600 // 3h — ignore naps

isBreak := func(s sample) bool { return s.hr > hrBreak || s.steps > stepsBreak }

type run struct {
start, end int64
hrSum int
hrCount int
}
var runs []run
var cur run
inRun := false
var lastTS int64

startRun := func(s sample) {
cur = run{start: s.ts, end: s.ts}
if s.hr > 0 {
cur.hrSum = s.hr
cur.hrCount = 1
}
inRun = true
}
extendRun := func(s sample) {
cur.end = s.ts
if s.hr > 0 {
cur.hrSum += s.hr
cur.hrCount++
}
}
flushRun := func() {
runs = append(runs, cur)
inRun = false
}

for _, s := range samples {
if isBreak(s) {
if inRun {
flushRun()
}
continue
}
switch {
case !inRun:
startRun(s)
case s.ts-lastTS > maxGap:
flushRun()
startRun(s)
default:
extendRun(s)
}
lastTS = s.ts
}
if inRun {
flushRun()
}

// Accept the longest run that looks like sleep: ≥ 3h, with enough HR data
// to confirm it's not just "watch not worn," and a mean HR in the sleep range.
var best *run
for i := range runs {
r := &runs[i]
if r.end-r.start < minDur {
continue
}
if r.hrCount < 10 {
continue
}
if float64(r.hrSum)/float64(r.hrCount) > 80 {
continue
}
if best == nil || r.end-r.start > best.end-best.start {
best = r
}
}
if best == nil {
return nil, nil
}

bestStart := best.start
bestEnd := best.end
bestDur := best.end - best.start
hrAvg := float64(best.hrSum) / float64(best.hrCount)

// Stuff total duration into lightsleepduration so the existing CSV writer's
// total_sleep_min = (light+deep+rem)/60 renders correctly. Derived rows
// have no stage breakdown, so all sleep time shows as "light".
dataObj := map[string]any{
"lightsleepduration": bestDur,
"hr_average": hrAvg,
}
data, _ := json.Marshal(dataObj)

return &sleepSeries{
Timezone: loc.String(),
StartDate: bestStart,
EndDate: bestEnd,
Date: date.Format("2006-01-02"),
Data: json.RawMessage(data),
Source: "derived",
}, nil
}

func writeSleepCSV(series []sleepSeries) error {
w := csv.NewWriter(os.Stdout)
defer w.Flush()
Expand All @@ -87,6 +275,7 @@ func writeSleepCSV(series []sleepSeries) error {
"time_to_sleep_sec", "time_to_wakeup_sec", "wakeup_count",
"sleep_score", "hr_avg", "hr_min", "hr_max",
"rr_avg", "rr_min", "rr_max", "snore_episodes", "apnea_hypopnea_index",
"source",
}
if err := w.Write(header); err != nil {
return err
Expand Down Expand Up @@ -138,6 +327,7 @@ func writeSleepCSV(series []sleepSeries) error {
strconv.FormatFloat(d.RRMax, 'f', -1, 64),
strconv.Itoa(d.SnoreEpisodes),
strconv.FormatFloat(d.ApneaHypopnea, 'f', -1, 64),
s.Source,
}
if err := w.Write(row); err != nil {
return err
Expand All @@ -151,4 +341,6 @@ func init() {
"Filter on or after date (e.g. 2026-01-01, 30d, 4w, 6m, 1y; default 30d)")
sleepCmd.Flags().BoolVar(&sleepJSONFlag, "json", false,
"Output as JSON instead of CSV")
sleepCmd.Flags().BoolVar(&sleepDeriveFlag, "derive", false,
"For nights with no Withings sleep summary, polyfill start/end from intraday heart-rate samples")
}