Skip to content

Explain empty SpO2/respiration Deep-timeline tracks on WHOOP 5.0 (#623) - #673

Merged
ryanbr merged 3 commits into
mainfrom
fix/whoop5-timeline-empty-state-623
Jul 20, 2026
Merged

Explain empty SpO2/respiration Deep-timeline tracks on WHOOP 5.0 (#623)#673
ryanbr merged 3 commits into
mainfrom
fix/whoop5-timeline-empty-state-623

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Addresses #623 (WHOOP 5.0: "no SpO2 and respiration data captured or shown").

What's actually happening

This isn't recoverable data that's hiding — for a 5.0/MG both Deep-timeline tracks are permanently empty, and the app just never said why:

  • SpO2 — the 5.0 v18 wire carries no SpO₂ channel at all (it's never decoded), and even where raw optical exists NOOP deliberately won't compute a calibrated % without WHOOP's proprietary curve (the Widget (iOS): throttle the live-HR republish to 60s (#114 follow-up) #194 fabrication trap). Documented in README.md:255. Not encrypted — decoded, just not present as a usable value.
  • Respiration — the Deep timeline plots a raw per-second ADC stream that only a WHOOP 4.0 emits. The 5.0 does get a respiratory rate, but as an R-R/RSA estimate on the Health screen (SleepStager.respRateFromRR), a different surface with a different source. That mismatch is what confused the reporter.

So there is no data fix possible here — this makes the existing, documented limitation legible instead of a blank that reads as broken.

Change

Model-aware empty state for the SpO2 + Respiration tracks, resolved via the canonical DeviceFamily.forRegistryModel (#171 — never a string compare):

  • SpO2 → "WHOOP 5.0 doesn't send SpO₂ over Bluetooth. Import a WHOOP export or Health Connect to see it."
  • Respiration → "WHOOP 5.0 sends no raw respiration stream. Your estimated respiratory rate appears on the Health screen."

Strap view only (ownedOnly) — with other sources included, the generic "nothing offloaded" copy still applies (imported SpO2 can exist). No model-gating hides the pills; they render the honest reason.

Both platforms:

  • AndroidFullDayChartScreen.EmptyTimelineState + isWhoop5 resolved from vm.pairedDevices().
  • iOSFullDayChartView.emptyReason + new Repository.activeStrapFamily() (mirrors the existing skinTempFamilies registry read).

Not in scope

The related SpO2 capability-label / Health-card work (registry advertises spo2 it never produces) is #548 — left there. This PR is only the Deep-timeline empty state the reporter hit.

Verification

  • ./gradlew compileFullDebugKotlin
  • python3 Tools/i18n_audit.py ✓ (0 gaps; new Android strings localized de/es/fr/zh)
  • ./gradlew lintVitalFullRelease -PstagingRelease
  • iOS is app-target Swift (no Linux/CI build) — compiles on the next staging build. The 2 new iOS strings fall back to English on de/es/fr until a translator fills the catalog (non-fatal; a follow-up).

Suggested issue reply

Close as a documented 5.0 limitation with the import workaround: WHOOP CSV export carries blood_oxygen_pct, which WhoopCsvImporter maps straight to the Blood Oxygen card — the only way to see SpO2 alongside NOOP on a 5.0.

ryanbr added 3 commits July 20, 2026 16:55
On a 5.0/MG the Deep-timeline SpO2 and Respiration tracks are permanently
empty — both plot raw per-second ADC streams that only a WHOOP 4.0 emits.
The 5.0 v18 wire has no SpO2 channel at all (and a calibrated % needs
WHOOP's proprietary curve, which NOOP won't fabricate), and no raw
respiration channel (respiration on a 5.0 is instead an R-R/RSA estimate
shown on the Health screen). Previously both showed a generic "nothing
offloaded", which reads as a bug — the report in #623.

Give those two tracks a model-aware empty state (resolved via the canonical
DeviceFamily.forRegistryModel, not a string compare): SpO2 points at import,
respiration points at the Health screen's estimate. Strap view only; with
imports included the generic copy still applies. Both platforms (Android
FullDayChartScreen + iOS FullDayChartView, new Repository.activeStrapFamily).

No data change — this only makes an existing, documented limitation legible.
New strings localized de/es/fr/zh (Android).

Verified: compileFullDebugKotlin, i18n_audit (0 gaps), lintVitalFullRelease
-PstagingRelease. iOS is app-target Swift (no Linux/CI build) — compiles on
the next staging build; de/es/fr catalog entries for the 2 new iOS strings
are a translator follow-up (English fallback until then).

Author: ryanbr <mp3geek@gmail.com>
DeviceFamily.forRegistryModel resolves nil/unknown/legacy-bare-"WHOOP"
models to .whoop5 (its documented fallback), so a legacy-paired WHOOP 4.0
on a firmware layout that decodes no SpO2 (v25/v5/7/9 → empty track) would
resolve isWhoop5=true and be told "WHOOP 5.0 doesn't send SpO2" — a false
claim about its model. Keep the canonical resolver as the gate (per #171),
but make the copy model-agnostic ("This strap ...") so it's true for a real
5.0 AND for any ambiguous device that reaches it. Both platforms + locales.

Author: ryanbr <mp3geek@gmail.com>
…623)

Re-review follow-up. The empty-state message was gated only on the 5.0
FAMILY, but DeviceFamily.forRegistryModel maps legacy bare-"WHOOP" models
to the 5.0 family, and a 4.0-v24 DOES bank SpO2. So a legacy-paired 4.0-v24
could show its SpO2 track on some days yet say "this strap doesn't send
SpO2" on empty windows — a self-contradiction.

Gate the copy on 5.0-family AND the strap having NEVER produced that metric
(a cheap limit-1 sample read on both platforms), so a strap with any history
keeps the neutral "nothing offloaded". everSpo2/everResp default true so a
4.0-v24 never flashes the wrong message before the reads resolve.

Also add docs/WHOOP5_DEEP_DATA.md § "Why SpO2 ... aren't available on 5.0"
— the canonical, linkable explanation (not encrypted; absent from the wire;
needs WHOOP's calibration; 4.0-v24 difference; import workaround) for the
recurring "why isn't SpO2 supported" question.

Verified: compileFullDebugKotlin, i18n_audit (0 gaps), lintVitalFullRelease
-PstagingRelease. iOS (Repository.strapHasEverProduced + view) compiles on
the next staging build.

Author: ryanbr <mp3geek@gmail.com>
@ryanbr
ryanbr merged commit 8b886c0 into main Jul 20, 2026
4 checks passed
@ryanbr
ryanbr deleted the fix/whoop5-timeline-empty-state-623 branch July 20, 2026 05:27
ryanbr added a commit that referenced this pull request Jul 20, 2026
Marketing version stays 9.0.3; Android versionCode 296->297 and iOS
CURRENT_PROJECT_VERSION 206->207 so the new staging build installs over the
previous one. Content since the last testing build: Health Connect writeback
failure surfacing (#672), SpO2/respiration empty-track explanation (#673),
WHOOP 5/MG raw-IMU offload persist (#675), hide stale sleep recompute entries
(#679), Oura activity MET research corpus + cap/rotation (#676/#680), trend
chart date alignment (#528), and corrupt-DB quarantine preservation (#681).
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…nbr#623) (ryanbr#673)

Model-aware empty state for the Deep-timeline SpO2/respiration tracks: a 5.0-family strap that has never produced the metric shows why (SpO2 → import; respiration → Health screen's RSA estimate) instead of a generic blank that reads as broken. Resolved via DeviceFamily.forRegistryModel + a never-produced check so a legacy 4.0-v24 with data keeps the neutral copy. Both platforms; iOS compile verified. Adds docs/WHOOP5_DEEP_DATA.md § on why SpO2 isn't decodable on 5.0. No data change. Fixes ryanbr#623.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
Marketing version stays 9.0.3; Android versionCode 296->297 and iOS
CURRENT_PROJECT_VERSION 206->207 so the new staging build installs over the
previous one. Content since the last testing build: Health Connect writeback
failure surfacing (ryanbr#672), SpO2/respiration empty-track explanation (ryanbr#673),
WHOOP 5/MG raw-IMU offload persist (ryanbr#675), hide stale sleep recompute entries
(ryanbr#679), Oura activity MET research corpus + cap/rotation (ryanbr#676/ryanbr#680), trend
chart date alignment (ryanbr#528), and corrupt-DB quarantine preservation (ryanbr#681).
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