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
26 changes: 26 additions & 0 deletions Strand/Data/Repository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,32 @@ final class Repository: ObservableObject {
return out
}

/// Family of the ACTIVE strap (#623), for the deep timeline's family-specific empty-state copy. Reuses
/// the canonical `DeviceFamily.forRegistryModel` (#171) with its `.whoop5` fallback for nil/unknown/
/// ambiguous, matching Android's `FullDayChartScreen`. Best-effort: no store / unreadable registry → `.whoop5`.
func activeStrapFamily() -> DeviceFamily {
guard let store else { return .whoop5 }
let devices = (try? DeviceRegistryStore(dbQueue: store.registryWriter).all()) ?? []
return DeviceFamily.forRegistryModel(devices.first(where: { $0.id == deviceId })?.model)
}

/// Whether the active strap has EVER banked a sample of `metric` (#623) — distinguishes a strap that
/// never produces it (honest "not supported on this strap" copy) from one with just an unsynced window.
/// Only SpO₂/respiration are asked; any other metric returns true so the generic empty copy stands.
/// Twin of the Android `FullDayChartScreen` `everSpo2`/`everResp` reads. Best-effort.
func strapHasEverProduced(_ metric: TimelineMetric) async -> Bool {
guard let store else { return true }
let now = Int(Date().timeIntervalSince1970)
switch metric {
case .spo2:
return !(((try? await store.spo2Samples(deviceId: deviceId, from: 0, to: now, limit: 1)) ?? []).isEmpty)
case .respiration:
return !(((try? await store.respSamples(deviceId: deviceId, from: 0, to: now, limit: 1)) ?? []).isEmpty)
default:
return true
}
}

/// Raw points for a non-HR timeline metric, mapped to display units (skin temp → °C DEVICE-FAMILY-AWARE
/// via `skinTempCelsius`: 5/MG centidegrees (#156), WHOOP 4.0 v24 raw ADC (#938); HRV → per-RR
/// instantaneous from RR ms; respiration/SpO₂/motion as the stored signal). Empty when the strap
Expand Down
35 changes: 32 additions & 3 deletions Strand/Screens/FullDayChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ struct FullDayChartView: View {
/// "Owned only" hides empty non-strap rows; "All sources" surfaces the disclosure (#574). The strap is
/// always the owned source, so this currently scopes the empty-state copy rather than swapping reads.
@State private var ownedOnly = true
/// #623: true when the current SpO2/respiration metric is genuinely unsupported on the active strap —
/// a 5.0-family strap that has NEVER produced it (4.0-only wire signals) — vs merely an empty window.
@State private var metricUnsupported = false

@State private var series: Repository.TimelineSeries = .empty
// #979 spin-off — day annotations, mirroring the classic Today's Overview HR markers: the main
Expand Down Expand Up @@ -89,6 +92,18 @@ struct FullDayChartView: View {
.task(id: taskKey) { await reload() }
.task(id: annotationKey) { await reloadAnnotations() }
.task { await landOnLatestDayIfNeeded() }
.task(id: metric) { await resolveMetricUnsupported() } // #623
}

/// #623: a SpO2/respiration track is "unsupported on this strap" only when it's a 5.0-family strap that
/// has never produced the metric — not merely an empty window (a 4.0-v24 banks SpO2, and the legacy
/// bare-"WHOOP" model resolves to the 5.0 family). Re-resolves when the metric changes.
private func resolveMetricUnsupported() async {
guard repo.activeStrapFamily() == .whoop5, metric == .spo2 || metric == .respiration else {
metricUnsupported = false
return
}
metricUnsupported = !(await repo.strapHasEverProduced(metric))
}

/// Annotations re-read only when the shown day changes or fresh strap data lands — deliberately NOT
Expand Down Expand Up @@ -273,9 +288,7 @@ struct FullDayChartView: View {
Text("No \(metric.title.lowercased()) here")
.font(StrandFont.body)
.foregroundStyle(StrandPalette.textSecondary)
Text(ownedOnly
? "Nothing offloaded for this window yet."
: "Other sources don’t offload raw per-second data on-device.")
Text(emptyReason)
.font(StrandFont.footnote)
.foregroundStyle(StrandPalette.textTertiary)
.multilineTextAlignment(.center)
Expand All @@ -284,6 +297,22 @@ struct FullDayChartView: View {
.padding(.horizontal, NoopMetrics.space6)
}

/// #623: on a 5.0/MG the SpO2 + raw respiration tracks are PERMANENTLY empty (4.0-only wire signals),
/// so say that instead of a generic "nothing offloaded" that reads as broken, and point respiration at
/// the Health screen where the R-R/RSA estimate surfaces. Strap view only (ownedOnly). Twin of Android
/// FullDayChartScreen.EmptyTimelineState.
private var emptyReason: String {
if ownedOnly, metricUnsupported, metric == .spo2 {
return String(localized: "This strap doesn’t send SpO₂ over Bluetooth. Import a WHOOP export or Health Connect to see it.")
}
if ownedOnly, metricUnsupported, metric == .respiration {
return String(localized: "This strap sends no raw respiration stream. Your estimated respiratory rate appears on the Health screen.")
}
return ownedOnly
? String(localized: "Nothing offloaded for this window yet.")
: String(localized: "Other sources don’t offload raw per-second data on-device.")
}

@ViewBuilder private var zoomHint: some View {
HStack(spacing: NoopMetrics.space2) {
Image(systemName: zoomDomain == nil ? "arrow.up.left.and.arrow.down.right" : "arrow.down.right.and.arrow.up.left")
Expand Down
50 changes: 43 additions & 7 deletions android/app/src/main/java/com/noop/ui/FullDayChartScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,24 @@ fun FullDayChartScreen(vm: AppViewModel, onBack: () -> Unit) {

var metric by remember { mutableStateOf(TimelineMetric.Hr) }
var ownedOnly by remember { mutableStateOf(true) }
// #623: is an empty SpO2 / respiration track "unsupported on this strap" or just "not this window"?
// A 5.0/MG never decodes either (4.0-only wire signals). But the canonical registry-model resolver
// (#171) maps legacy bare-"WHOOP" 4.0s to the 5.0 family too, and a 4.0-v24 DOES bank SpO2 — so gate
// the "not supported" copy on 5.0-family AND the strap having NEVER produced that metric, else a legacy
// 4.0-v24 with data on other days would contradict itself. `ever*` default true (assume produced) so a
// 4.0-v24 never flashes the wrong message before the async reads resolve.
var isWhoop5 by remember { mutableStateOf(false) }
var everSpo2 by remember { mutableStateOf(true) }
var everResp by remember { mutableStateOf(true) }
LaunchedEffect(deviceId) {
val model = runCatching { vm.pairedDevices() }.getOrDefault(emptyList())
.firstOrNull { it.id == deviceId }?.model
val whoop5 = DeviceFamily.forRegistryModel(model) == DeviceFamily.WHOOP5
isWhoop5 = whoop5
val now = System.currentTimeMillis() / 1000
everSpo2 = !whoop5 || runCatching { vm.repo.spo2Samples(deviceId, 0, now, 1) }.getOrDefault(emptyList()).isNotEmpty()
everResp = !whoop5 || runCatching { vm.repo.respSamples(deviceId, 0, now, 1) }.getOrDefault(emptyList()).isNotEmpty()
}
// The visible window the gestures drive; null → the whole day.
var window by remember { mutableStateOf<LongRange?>(null) }
val visible = window ?: dayBounds
Expand Down Expand Up @@ -237,7 +255,16 @@ fun FullDayChartScreen(vm: AppViewModel, onBack: () -> Unit) {
when {
loading && points.isEmpty() ->
Text(stringResource(R.string.timeline_loading_day), style = NoopType.footnote, color = Palette.textTertiary)
points.isEmpty() -> EmptyTimelineState(metric, ownedOnly)
points.isEmpty() -> {
// #623: the metric is genuinely UNSUPPORTED on this strap only when it's a
// 5.0-family strap that has never produced it — not merely an empty window.
val metricUnsupported = ownedOnly && isWhoop5 && when (metric) {
TimelineMetric.Spo2 -> !everSpo2
TimelineMetric.Respiration -> !everResp
else -> false
}
EmptyTimelineState(metric, ownedOnly, metricUnsupported)
}
else -> TimelineChart(
points = displayPoints,
windowStart = visible.first,
Expand Down Expand Up @@ -281,19 +308,28 @@ fun FullDayChartScreen(vm: AppViewModel, onBack: () -> Unit) {
}

@Composable
private fun EmptyTimelineState(metric: TimelineMetric, ownedOnly: Boolean) {
private fun EmptyTimelineState(metric: TimelineMetric, ownedOnly: Boolean, metricUnsupported: Boolean) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.timeline_empty_metric, metric.title.lowercase(Locale.US)),
style = NoopType.body, color = Palette.textSecondary)
Text(
if (ownedOnly) stringResource(R.string.timeline_nothing_offloaded)
else stringResource(R.string.timeline_other_sources_no_offload),
style = NoopType.footnote, color = Palette.textTertiary, textAlign = TextAlign.Center,
)
// #623: when SpO2 / raw respiration is genuinely unsupported on this strap (a 5.0-family strap that
// has never produced it — those are 4.0-only wire signals), say so instead of a generic "nothing
// offloaded" that reads as broken, and point respiration at the Health screen where the R-R/RSA
// estimate surfaces. [metricUnsupported] already folds in the family + never-produced + ownedOnly
// gate, so a 4.0-v24 with data on other days keeps the generic message.
val reason = when {
metricUnsupported && metric == TimelineMetric.Spo2 ->
stringResource(R.string.timeline_spo2_not_on_whoop5)
metricUnsupported && metric == TimelineMetric.Respiration ->
stringResource(R.string.timeline_resp_not_on_whoop5)
ownedOnly -> stringResource(R.string.timeline_nothing_offloaded)
else -> stringResource(R.string.timeline_other_sources_no_offload)
}
Text(reason, style = NoopType.footnote, color = Palette.textTertiary, textAlign = TextAlign.Center)
}
}

Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1697,6 +1697,8 @@
<item quantity="one">%1$d Workout</item>
<item quantity="other">%1$d Workouts</item>
</plurals>
<string name="timeline_spo2_not_on_whoop5">Dieses Band sendet kein SpO₂ über Bluetooth. Importiere einen WHOOP-Export oder Health Connect, um es zu sehen.</string>
<string name="timeline_resp_not_on_whoop5">Dieses Band sendet keinen Rohdaten-Atemstrom. Deine geschätzte Atemfrequenz erscheint im Health-Bildschirm.</string>
<string name="l10n_data_sources_screen_last_shared_6bf8389c">"Zuletzt geteilt "</string>
<string name="l10n_data_sources_screen_sharing_paused_health_connect_permission_was_e8950315">Teilen pausiert – die Health-Connect-Berechtigung wurde entzogen. Zum erneuten Erteilen tippen.</string>
<string name="l10n_data_sources_screen_last_share_didn_t_finish_noop_0d6c27f0">Letztes Teilen unvollständig – NOOP versucht es automatisch erneut.</string>
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1682,6 +1682,8 @@
<item quantity="one">%1$d entrenamiento</item>
<item quantity="other">%1$d entrenamientos</item>
</plurals>
<string name="timeline_spo2_not_on_whoop5">Esta correa no envía SpO₂ por Bluetooth. Importa una exportación de WHOOP o Health Connect para verlo.</string>
<string name="timeline_resp_not_on_whoop5">Esta correa no envía datos de respiración sin procesar. Tu frecuencia respiratoria estimada aparece en la pantalla Salud.</string>
<string name="l10n_data_sources_screen_last_shared_6bf8389c">"Compartido por última vez "</string>
<string name="l10n_data_sources_screen_sharing_paused_health_connect_permission_was_e8950315">Uso compartido en pausa: se revocó el permiso de Health Connect. Toca para volver a concederlo.</string>
<string name="l10n_data_sources_screen_last_share_didn_t_finish_noop_0d6c27f0">El último uso compartido no se completó: NOOP lo reintentará automáticamente.</string>
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1682,6 +1682,8 @@
<item quantity="one">%1$d entraînement</item>
<item quantity="other">%1$d entraînements</item>
</plurals>
<string name="timeline_spo2_not_on_whoop5">Ce bracelet n\'envoie pas de SpO₂ par Bluetooth. Importez un export WHOOP ou Health Connect pour le voir.</string>
<string name="timeline_resp_not_on_whoop5">Ce bracelet n\'envoie aucun flux de respiration brut. Votre fréquence respiratoire estimée apparaît sur l\'écran Santé.</string>
<string name="l10n_data_sources_screen_last_shared_6bf8389c">"Dernier partage "</string>
<string name="l10n_data_sources_screen_sharing_paused_health_connect_permission_was_e8950315">Partage en pause — l’autorisation Health Connect a été révoquée. Touchez pour l’accorder à nouveau.</string>
<string name="l10n_data_sources_screen_last_share_didn_t_finish_noop_0d6c27f0">Le dernier partage n’a pas abouti — NOOP réessaiera automatiquement.</string>
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-zh/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1623,6 +1623,8 @@
<string name="explore_description_charge">你当前的恢复水平,以 HRV 相对于你个人基线的表现为主导。</string>
<string name="explore_description_effort">全天累积的心血管负荷,采用 0-100 分制(旧版为 0-21)。</string>
<string name="explore_description_rest">你的睡眠质量与修复力:综合睡眠时长、睡眠效率、深睡+REM占比以及入睡时机等。</string>
<string name="timeline_spo2_not_on_whoop5">此设备不通过蓝牙发送 SpO₂。导入 WHOOP 数据或 Health Connect 即可查看。</string>
<string name="timeline_resp_not_on_whoop5">此设备不发送原始呼吸数据流。你的估算呼吸频率显示在“健康”屏幕上。</string>
<string name="l10n_data_sources_screen_last_shared_6bf8389c">"上次分享 "</string>
<string name="l10n_data_sources_screen_sharing_paused_health_connect_permission_was_e8950315">分享已暂停——Health Connect 权限已被撤销。点按以重新授予。</string>
<string name="l10n_data_sources_screen_last_share_didn_t_finish_noop_0d6c27f0">上次分享未完成——NOOP 将自动重试。</string>
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1706,6 +1706,8 @@
<item quantity="one">%1$d workout</item>
<item quantity="other">%1$d workouts</item>
</plurals>
<string name="timeline_spo2_not_on_whoop5">This strap doesn\'t send SpO₂ over Bluetooth. Import a WHOOP export or Health Connect to see it.</string>
<string name="timeline_resp_not_on_whoop5">This strap sends no raw respiration stream. Your estimated respiratory rate appears on the Health screen.</string>
<string name="l10n_data_sources_screen_last_shared_6bf8389c">"Last shared "</string>
<string name="l10n_data_sources_screen_sharing_paused_health_connect_permission_was_e8950315">Sharing paused — Health Connect permission was revoked. Tap to re-grant.</string>
<string name="l10n_data_sources_screen_last_share_didn_t_finish_noop_0d6c27f0">Last share didn’t finish — NOOP will retry automatically.</string>
Expand Down
36 changes: 36 additions & 0 deletions docs/WHOOP5_DEEP_DATA.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,42 @@ that otherwise reproduced flags 1–15 byte-for-byte in this order.
we map the type-`0x2F` layout (documented as HR @ byte 14, accel x/y/z float32 @ 37/41/45) and feed the
motion into NOOP's existing v25-style sleep stager.

## Why SpO₂ (and the raw respiration track) aren't available on 5.0

This is the single most common "is it broken?" report (e.g. [#623](https://github.com/ryanbr/noop/issues/623)),
so the reasoning in one place:

**It is not an encryption problem.** NOOP decodes the entire 5.0 (v18) record in plaintext — HR, R-R,
sleep, and the whole optical tail. Nothing on the wire is hidden behind a cipher NOOP would need a key
for. The barrier is that the SpO₂ data simply isn't *in* the stream in a usable form:

- **No SpO₂ field on the 5.0 wire.** The v18 historical layout carries no blood-oxygen value. The raw
optical tail (`@106` baseline, `@108/@109` amplitude pair, `@113` float) was checked against WHOOP-app
SpO₂ across 18,602 real records — it does not match; those channels track HR/motion, and there is no
identifiable red/IR pair. Pulse oximetry fundamentally needs two wavelengths; the 5.0's decodable
stream doesn't expose them (the v26 PPG waveform is single-channel, HR only).
- **A calibrated % needs WHOOP's proprietary curve.** Even where raw optical exists, turning a red/IR
ratio into a real SpO₂ % requires a device-specific calibration NOOP does not have — and NOOP will not
fabricate one from unvalidated optical (the withdrawn #194 PPG→HR estimate is the cautionary
precedent). `spo2Pct` is therefore nulled for *every* WHOOP; only an import writes it.

**WHOOP 4.0 differs.** The 4.0 **v24** historical layout *does* bank raw SpO₂ channels (`spo2_red@68` /
`spo2_ir@70`), so NOOP decodes the raw red/IR there (still not a calibrated %). The 5.0's v18 layout
dropped those channels — most likely SpO₂ moved to a value computed on-device / in WHOOP's cloud rather
than banked in the offload NOOP reads. NOOP reverse-engineers what the strap actually sends; if a
decodable SpO₂ isn't sent, there is nothing to decode, plaintext or not.

**Respiration is a partial exception.** The 5.0 sends no raw respiration ADC stream either (also
4.0-v24-only), so the deep-timeline *track* is empty — but respiration is still estimated on-device from
the R-R interval stream (RSA) and shown on the Health screen when enough overnight R-R is captured.

**To see SpO₂ in NOOP on a 5.0:** import it. A WHOOP data export carries `blood_oxygen_pct`, and Health
Connect import works too — both populate the Blood Oxygen card with WHOOP's own computed values.

**Could it ever change?** Only via research, not decryption: capture the 5.0's raw optical alongside a
reference oximeter and prove a channel tracks true SpO₂ (a varying signal, not one coincidental match).
Until that clears the bar, SpO₂ stays import-only on the 5.0.

## Mapping the layout — ground-truth correlation

An HCI capture on its own is a pile of un-labelled bytes. The fast way to label them is *known
Expand Down
Loading