diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 9197bb34b..ef0430e9e 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -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 diff --git a/Strand/Screens/FullDayChartView.swift b/Strand/Screens/FullDayChartView.swift index 63df36cca..9bb4052c0 100644 --- a/Strand/Screens/FullDayChartView.swift +++ b/Strand/Screens/FullDayChartView.swift @@ -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 @@ -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 @@ -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) @@ -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") diff --git a/android/app/src/main/java/com/noop/ui/FullDayChartScreen.kt b/android/app/src/main/java/com/noop/ui/FullDayChartScreen.kt index 899b7dfc4..d60a8215d 100644 --- a/android/app/src/main/java/com/noop/ui/FullDayChartScreen.kt +++ b/android/app/src/main/java/com/noop/ui/FullDayChartScreen.kt @@ -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(null) } val visible = window ?: dayBounds @@ -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, @@ -281,7 +308,7 @@ 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), @@ -289,11 +316,20 @@ private fun EmptyTimelineState(metric: TimelineMetric, ownedOnly: Boolean) { ) { 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) } } diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index a531b102b..8ee14f900 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -1697,6 +1697,8 @@ %1$d Workout %1$d Workouts + Dieses Band sendet kein SpO₂ über Bluetooth. Importiere einen WHOOP-Export oder Health Connect, um es zu sehen. + Dieses Band sendet keinen Rohdaten-Atemstrom. Deine geschätzte Atemfrequenz erscheint im Health-Bildschirm. "Zuletzt geteilt " Teilen pausiert – die Health-Connect-Berechtigung wurde entzogen. Zum erneuten Erteilen tippen. Letztes Teilen unvollständig – NOOP versucht es automatisch erneut. diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 780b34163..7ba7100a6 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -1682,6 +1682,8 @@ %1$d entrenamiento %1$d entrenamientos + Esta correa no envía SpO₂ por Bluetooth. Importa una exportación de WHOOP o Health Connect para verlo. + Esta correa no envía datos de respiración sin procesar. Tu frecuencia respiratoria estimada aparece en la pantalla Salud. "Compartido por última vez " Uso compartido en pausa: se revocó el permiso de Health Connect. Toca para volver a concederlo. El último uso compartido no se completó: NOOP lo reintentará automáticamente. diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 27406634e..8aed5f15b 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -1682,6 +1682,8 @@ %1$d entraînement %1$d entraînements + Ce bracelet n\'envoie pas de SpO₂ par Bluetooth. Importez un export WHOOP ou Health Connect pour le voir. + Ce bracelet n\'envoie aucun flux de respiration brut. Votre fréquence respiratoire estimée apparaît sur l\'écran Santé. "Dernier partage " Partage en pause — l’autorisation Health Connect a été révoquée. Touchez pour l’accorder à nouveau. Le dernier partage n’a pas abouti — NOOP réessaiera automatiquement. diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 64160af7a..85ae492d9 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -1623,6 +1623,8 @@ 你当前的恢复水平,以 HRV 相对于你个人基线的表现为主导。 全天累积的心血管负荷,采用 0-100 分制(旧版为 0-21)。 你的睡眠质量与修复力:综合睡眠时长、睡眠效率、深睡+REM占比以及入睡时机等。 + 此设备不通过蓝牙发送 SpO₂。导入 WHOOP 数据或 Health Connect 即可查看。 + 此设备不发送原始呼吸数据流。你的估算呼吸频率显示在“健康”屏幕上。 "上次分享 " 分享已暂停——Health Connect 权限已被撤销。点按以重新授予。 上次分享未完成——NOOP 将自动重试。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 74c99ff4d..4b7e10755 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1706,6 +1706,8 @@ %1$d workout %1$d workouts + This strap doesn\'t send SpO₂ over Bluetooth. Import a WHOOP export or Health Connect to see it. + This strap sends no raw respiration stream. Your estimated respiratory rate appears on the Health screen. "Last shared " Sharing paused — Health Connect permission was revoked. Tap to re-grant. Last share didn’t finish — NOOP will retry automatically. diff --git a/docs/WHOOP5_DEEP_DATA.md b/docs/WHOOP5_DEEP_DATA.md index 76224e3c0..c31842935 100644 --- a/docs/WHOOP5_DEEP_DATA.md +++ b/docs/WHOOP5_DEEP_DATA.md @@ -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