Skip to content

fix(garmin): fold a watch night over its own sleep window - #48

Merged
vuongvm812 merged 1 commit into
mainfrom
47-garmin-sleep-split
Nov 3, 2026
Merged

vuongvm812 merged 1 commit into
mainfrom
47-garmin-sleep-split

Conversation

@vuongvm812

Copy link
Copy Markdown
Owner

Refs #47

Root cause (real data, Forerunner 165 fw 29.05)

Last night's files were pulled read-only from the watch with a scratch harness built on the app's own GarminSource. It sends no archive, flag or delete messages. All 48 FIT files were listed and downloaded, and nothing was changed on the watch. Times below are local (+07:00).

  • One sleep file (type 49) holds the whole night. It is written at wake (file ts 09:50:02) and has 15 SLEEP_LEVEL rows. Monitor files carry no SLEEP_LEVEL rows, so this is not a cross-file split.
  • Rows are written only when the stage changes, and each row closes the span before it. The deltas between rows include 66 min (03:16→04:22) and 138 min (04:45→07:03).
    • The old 1-hour gap rule therefore cut the night into 3 sessions: 01:53–03:16, 04:22–04:45 and 07:03–09:50.
    • The UI grouped the first two into the main sleep and showed the third as a nap.
  • The watch's own window is in EVENT messages (21) with event 74: start (type 0) at 01:36:17 and stop (type 1) at 09:50:00. The first level row (01:53:17) closes the 01:36–01:53 span, so starting the night at the first row dropped 17 minutes.
  • Folded over that window, the night is 01:36:17–09:50:00: light 265, deep 107, REM 98 min. That is 470 min asleep (7h50m) and 23 min awake, which matches the watch.
  • Suspect 4 is ruled out: for a Garmin owner, IntelligenceEngine never stages sleep from heart rate (watch-stages-its-own-sleep). Stale computed copies are dropped by the existing Publish the command result code on Apple, as Android already does (#894) ryanbr/noop#899 overlap heal after the post-sync rescore.

No FIT files or logs are committed.

Changes (Swift + Kotlin twins)

Parser (GarminWellnessParser):

  • Decodes EVENT 74 into sleepMarks.
  • sleepSessions folds each night over the watch window. The first row closes the span from onset.
  • Files without markers fall back to a 4 h gap rule, because rows only appear at stage changes.
  • The arity stays 1 (it now takes the samples), so the parity twin pair is unchanged.

Fold (GarminWatchIngest.stagesJSON): the stage timeline includes the onset span.

Store (ingestGarminFile / GarminSyncStore.ingest):

  • A night replaces the other stored nights of the same watch that it overlaps. User-edited nights are kept.
  • So a re-fold, a partial mid-night file followed by the full one, or a re-sync all stay one session, and re-ingest is idempotent.
  • New forgetGarminSyncedFiles / forgetFiles.

Repair of stored nights:

  • Once per watch (noop.garmin.sleepRefold.v1.<id>), the SLEEP-file ledger rows are forgotten, so the next sync re-downloads and re-folds them. The watch is not touched.
  • Nights older than the 14-day sync lookback keep the old fold.

Labels:

  • A Garmin night reads Garmin with Garmin watch stages, instead of "On-device" / "stages approximate (on-device)". This mirrors the Oura caption.
  • New strings are translated in the 9 locales.
  • The Android main-sleep footer badge no longer reads "Whoop" for a garmin-<id> night.

Tests

  • New fixture sleepWindow, shaped like this night (relative times only):
    • rows only at stage changes, with the 66 and 138 min stretches;
    • the first row 17 min after onset;
    • a stray stop, a non-sleep event and an unknown event type.
  • It folds into one night from onset to +29623 s, with 470.7 min asleep and 23 min awake.
  • The oracle stdout is pasted verbatim into both GarminWellnessParserTest(s) and GarminWatchIngestTest(s).
  • Store tests on both platforms:
    • the old split rows plus a partial night, then the full night, leave one session (re-ingest is idempotent);
    • a user-edited overlap is kept;
    • forgetting a file type leaves the other types.
  • SleepProvenanceLabelTest (Android) covers the Garmin and Oura captions and badges.

Local verification (Actions is disabled on this fork)

Check Result
swift test StrandImport, WhoopStore pass
xcodebuild -scheme Strand (macOS) BUILD SUCCEEDED
xcodebuild -scheme NOOPiOS (iOS Simulator; watch embed removed locally, not committed) BUILD SUCCEEDED
Android testFullDebugUnitTest (JDK 17, --no-build-cache --rerun-tasks) 6909 tests, 2 failed: RecoveryDriversTest half-tie rounding (-0.5 vs -0.4999999999999929). This PR changes no analytics code. Not checked on main.
parity_ratchet.py --base origin/main (after the guarded --refresh-derived) errors=0
doc_comment_lint.py OK
i18n_audit.py --ci main OK
Tools unittest only the 4 test_check_source_references failures, which also fail on clean main

Not yet confirmed in the app on the phone: after this build, the first Garmin sync should log re-folding stored nights; N sleep files will download again and show the night as one 01:36–09:50 session.

A Forerunner 165 writes a SLEEP_LEVEL row only when the stage changes, and
each row closes the span before it. The fold split nights at any gap over an
hour (one night became 3 sessions: a short main sleep plus a fake nap) and
started each night at its first row, dropping the onset span.

- Decode EVENT 74 (sleep start/stop) and fold each night over that window;
  the first row closes the span from onset. Files without markers fall back
  to a 4 h gap rule.
- A stored Garmin night replaces the overlapping nights of the same watch,
  so re-folds and re-syncs stay one session (user-edited nights are kept).
- Once per watch, forget stored SLEEP files in the ledger so the next sync
  re-downloads and re-folds them (read-only; files within the sync lookback).
- Label a Garmin night "Garmin" / "Garmin watch stages" instead of
  "On-device" / "stages approximate (on-device)".

Swift and Kotlin twins, oracle outputs pinned on both sides.

Refs #47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant