From b088c5216e5af7ace23dfb912a5e642c9ae34075 Mon Sep 17 00:00:00 2001 From: llmassisted Date: Sun, 27 Sep 2026 13:25:17 -0400 Subject: [PATCH 1/2] feat(oura): decode Ring 4 SpO2 (0x8b R-ratio) into the SpO2 estimate With SpO2 enabled via the Test Centre feature-mode write, a Ring 4 emits 0x8b spo2_r_pi records (5,236 on the first night) that were counted and discarded, so the night's SpO2 was lost once the ring trimmed its copy. - Add SPO2_R_PI (0x8b) to the tag dictionary and decode it: header byte, then 3-byte samples of u16 big-endian R / 16384 + u8 PI ([oura-rs] layout, confirmed on the Ring 4 capture). R becomes SpO2 via the cited gen4 quadratic, clamped 85-100 (OuraSpO2Ratio). - Samples are one per second and laid backward from the record time like 0x6F; a saturated PI (0xFF) sample is skipped but keeps its second (all 6 in the capture held the same R). - Persist as spo2Sample rows tagged r_pi_pct (new OuraSpO2Channel RATIO_PERCENTAGE), which the existing Oura SpO2 estimate path averages. Never written to spo2Pct, never scored. - Tests pinned to verbatim Ring 4 payloads; protocol doc s6.5.1 updated. versionCode 540. Co-Authored-By: Claude Opus 5.5 (1M context) --- Tools/parity_dispositions.json | 80 +++++++++++++++++-- Tools/parity_twin_map.json | 12 +-- android/app/build.gradle.kts | 2 +- .../main/java/com/noop/ble/OuraLiveSource.kt | 10 +-- .../java/com/noop/data/OuraStreamMapping.kt | 4 +- .../src/main/java/com/noop/oura/Decoders.kt | 35 ++++++++ .../src/main/java/com/noop/oura/EventTags.kt | 7 +- .../src/main/java/com/noop/oura/OuraDriver.kt | 2 + .../src/main/java/com/noop/oura/OuraEvents.kt | 10 ++- .../main/java/com/noop/oura/OuraSpO2Ratio.kt | 21 +++++ .../java/com/noop/oura/OuraSpO2RatioTest.kt | 72 +++++++++++++++++ docs/OURA_PROTOCOL.md | 5 +- 12 files changed, 231 insertions(+), 29 deletions(-) create mode 100644 android/app/src/main/java/com/noop/oura/OuraSpO2Ratio.kt create mode 100644 android/app/src/test/java/com/noop/oura/OuraSpO2RatioTest.kt diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 1d3494dbda..c4228fa482 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -289,14 +289,6 @@ "platform": "kotlin", "rationale": "Android port of the Swift Test Centre feature-mode experiment (#2105, OURA_PROTOCOL.md s7.5) for this Android-first fork. The Swift twins already exist in Packages/OuraProtocol; the Kotlin builders keep the same bytes and are reachable only from the Android Test Centre behind a confirmation, never automatically." }, - { - "type": "platform_specific", - "kind": "add-unpaired-constant", - "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/EventTags.kt::SPO2_R_PI_UNDECODED", - "identity_sha256": "a3712c91c37eb0465f18ce767a15017a29039d9de56adbc252a10e3b5ac4db76", - "platform": "kotlin", - "rationale": "Android-only capture constant for the never-received 0x8b spo2_r_pi tag (OURA_PROTOCOL.md s6.5.1): the history drain logs its first raw payloads as decoder fixtures. No layout is decoded or stored." - }, { "type": "platform_specific", "kind": "add-unpaired-function", @@ -424,6 +416,78 @@ "identity_sha256": "66bff0ba4ddae17d3628a64dcd7e16e27d5f6b979b0971f36a92d5111b379bb9", "platform": "kotlin", "rationale": "Kotlin twin of the Swift OuraActivityEstimator (Packages/OuraProtocol/.../ActivityEstimate.swift). Byte-identical output is pinned by OuraActivityEstimatorTest against the verbatim stdout of the Swift source compiled standalone over 300 generated inputs; the ledger cannot pair it by name because both platforms overload `estimate`." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/OuraEvents.kt::RATIO_PERCENT_UNIT", + "identity_sha256": "45a61921ac2064b62ce539181f06665077c4761da490e2994ce3bd0e3d4e7d8a", + "platform": "kotlin", + "rationale": "Android-only decode of the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1) into a phone-derived SpO2 percentage (gen4 quadratic cited from [open_oura-spo2]), tagged r_pi_pct and surfaced only through the SpO2 estimate display path. The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/OuraSpO2Ratio.kt::COEF_A", + "identity_sha256": "a2056e203b381c266ca9fced680aeb0a7a7c7543a42cc22b46c9eda98e709fcc", + "platform": "kotlin", + "rationale": "Android-only decode of the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1) into a phone-derived SpO2 percentage (gen4 quadratic cited from [open_oura-spo2]), tagged r_pi_pct and surfaced only through the SpO2 estimate display path. The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/OuraSpO2Ratio.kt::COEF_B", + "identity_sha256": "51718cd3f6b5f9f87080fc1eba1e0e183450a7fc8337f27e8818b66e4ecc6ed3", + "platform": "kotlin", + "rationale": "Android-only decode of the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1) into a phone-derived SpO2 percentage (gen4 quadratic cited from [open_oura-spo2]), tagged r_pi_pct and surfaced only through the SpO2 estimate display path. The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/OuraSpO2Ratio.kt::COEF_C", + "identity_sha256": "fa9e607f89e17d69777e680f8f7efd8f8985420c192d6c5652d2640ccccce277", + "platform": "kotlin", + "rationale": "Android-only decode of the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1) into a phone-derived SpO2 percentage (gen4 quadratic cited from [open_oura-spo2]), tagged r_pi_pct and surfaced only through the SpO2 estimate display path. The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/OuraSpO2Ratio.kt::MAX_PERCENT", + "identity_sha256": "73e5812e7a5f4625fb87a0e766fc6b093b8ae6d6ef623206c85c76dc4e9d2d9e", + "platform": "kotlin", + "rationale": "Android-only decode of the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1) into a phone-derived SpO2 percentage (gen4 quadratic cited from [open_oura-spo2]), tagged r_pi_pct and surfaced only through the SpO2 estimate display path. The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/OuraSpO2Ratio.kt::MIN_PERCENT", + "identity_sha256": "816ea049dde93987ac87346ede773b28a9daa58c8da521a137e9e75c830ad2cc", + "platform": "kotlin", + "rationale": "Android-only decode of the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1) into a phone-derived SpO2 percentage (gen4 quadratic cited from [open_oura-spo2]), tagged r_pi_pct and surfaced only through the SpO2 estimate display path. The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-file", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/OuraSpO2Ratio.kt", + "identity_sha256": "b0d142779a18682c88f529ad0d49551e5b9d2c1a44bcf0b1fba721e5164bb46c", + "platform": "kotlin", + "rationale": "Android-only decode of the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1) into a phone-derived SpO2 percentage (gen4 quadratic cited from [open_oura-spo2]), tagged r_pi_pct and surfaced only through the SpO2 estimate display path. The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/Decoders.kt::decodeSpO2RatioPi/1#1", + "identity_sha256": "52d3b869b4ac64313faef0c0a61e4ea86f2dbf7a725d9540ed83dabad435fe59", + "platform": "kotlin", + "rationale": "Android-only decode of the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1) into a phone-derived SpO2 percentage (gen4 quadratic cited from [open_oura-spo2]), tagged r_pi_pct and surfaced only through the SpO2 estimate display path. The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/OuraSpO2Ratio.kt::percent/1#1", + "identity_sha256": "c513f2be9fff544a5dbee95f54f28458bec979b5a3c57292191ac3f88f1970a7", + "platform": "kotlin", + "rationale": "Android-only decode of the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1) into a phone-derived SpO2 percentage (gen4 quadratic cited from [open_oura-spo2]), tagged r_pi_pct and surfaced only through the SpO2 estimate display path. The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index f7e7b3677e..627e0edf53 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -18,17 +18,17 @@ ] }, "authority": { - "files": {"count": 506, "sha256": "7c1aa896ae06001a3869be1164233da7d6af9f909334334a917be56b4832c511"}, - "functions": {"count": 4503, "sha256": "3cd89d9b79d6228b0e44fc2c89b272eb76a54a903e805bcec142f6da416cbce5"}, + "files": {"count": 507, "sha256": "6a87e93c099553fef09c336bb353e370da21b31c097ec5bb81cb57c036d70655"}, + "functions": {"count": 4505, "sha256": "9265257d682dc5c290ac16b95aa18e9591566c0f27aa06a5025895c416c2307a"}, "properties": {"count": 464, "sha256": "85338b70168a36af785255eb8959a41272cc02334529c2e3ef2a7d18444c7ef7"}, - "constants": {"count": 1968, "sha256": "2c88ab0d322028ad38a0bdf4118894231ecbb7200ab28afe095e5de336a8d7bb"}, + "constants": {"count": 1973, "sha256": "8f2eaf7572fd4dcb89505cc1745e956e87ac431aec03f82adcf3dd7d4441b72d"}, "file_pairs": {"count": 74, "sha256": "1023813dc72dbe4122281d5bae27ac2b7a657dae5904cb5fd603032928199f94"}, "function_pairs": {"count": 189, "sha256": "11df8bf87b16b535aeddd0c00471f87431236fd0b059484c07f6d5939ace9732"}, "property_pairs": {"count": 149, "sha256": "8a62616eca6270e3d8899c47cfe8ae93691f16846ed1959e19a762827b6ec185"}, "constant_pairs": {"count": 681, "sha256": "3484c8096ac8d2832e75f18900c052a19bfb7d92cbc2fa7fdb6137d8fa46ebf6"}, - "unpaired_files": {"count": 380, "sha256": "6e9cd41c3ee7c16a244e5ef95ff80cb31b27838afbab860416754bc91559adfe"}, - "unpaired_functions": {"count": 4137, "sha256": "e4abeab38983324cb9716af8d6be4518ca80ce123f13785ee27d2795be306a00"}, + "unpaired_files": {"count": 381, "sha256": "2efab44c14d7e749c9b48a1521b6d3b5dfd6ff856540bb22ffde6f02efe75732"}, + "unpaired_functions": {"count": 4139, "sha256": "e7397024ec4a2423a9f51802dea570a1ea06ffd7230ca8c52893409cfddbbdd4"}, "unpaired_properties": {"count": 166, "sha256": "280d96f0905fd623dfb66af86c99c3e82409b126a45aaf9e008eb8f6cf04671a"}, - "unpaired_constants": {"count": 606, "sha256": "d334a29da52e658603db294deb3ff23e9625d682b1914703fc3bb09ebc97ac9a"} + "unpaired_constants": {"count": 611, "sha256": "5c25d5051093b9351502ce152f7124a262b285292e838331448576bf096a76b5"} } } diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a89f662a36..75a656976c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -26,7 +26,7 @@ android { applicationId = "com.noop.whoop" minSdk = 26 targetSdk = 34 - versionCode = 539 + versionCode = 540 versionName = "11.8.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt index 2892e082b0..dc07c7ec94 100644 --- a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt +++ b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt @@ -385,9 +385,9 @@ class OuraLiveSource( /** Feature ids whose status we have already logged this session (SpO2 0x04 / real_steps 0x0b), so the * read-only feature-status diagnostic prints once per feature, not on every reconnect. */ private val loggedFeatureStatuses = mutableSetOf() - /** `0x8b` spo2_r_pi records whose raw payload has been logged this session. NOOP has never received the - * tag (OURA_PROTOCOL.md s6.5.1); if enabling SpO2 makes a Ring 4 emit it, the first few raw payloads are - * the fixtures a decoder must be validated against. Capped so a full night cannot flood the log. */ + /** `0x8b` spo2_r_pi records whose raw payload has been logged this session (OURA_PROTOCOL.md s6.5.1). + * A Ring 4 emits them once SpO2 is enabled; the first few raw payloads per session stay in the log as + * fixtures for the R-ratio decode. Capped so a full night cannot flood the log. */ private var spo2RatioRecordsLogged = 0 /** Product-info replies already logged this session, keyed by op+body so the #771/#772 serial/hardware * capture prints each DISTINCT reply once — get_serial and get_hardware both answer under op 0x19, so a @@ -2322,11 +2322,11 @@ class OuraLiveSource( drain.noteSeenRingTime(rec.ringTimestamp) historyRecordCount += 1 historyTagCounts[rec.type] = (historyTagCounts[rec.type] ?: 0L) + 1L - if (rec.type == com.noop.oura.OuraEventTag.SPO2_R_PI_UNDECODED && spo2RatioRecordsLogged < SPO2_R_PI_LOG_LIMIT) { + if (rec.type == com.noop.oura.OuraEventTag.SPO2_R_PI.raw && spo2RatioRecordsLogged < SPO2_R_PI_LOG_LIMIT) { spo2RatioRecordsLogged += 1 log("Oura: spo2_r_pi (0x8b) raw rt=${rec.ringTimestamp} payload=" + rec.payload.joinToString("") { "%02x".format(it) } + - " - UNDECODED, capture for OURA_PROTOCOL.md s6.5.1") + " - fixture for the OURA_PROTOCOL.md s6.5.1 R-ratio decode") } } val events = d.ingest(rec) diff --git a/android/app/src/main/java/com/noop/data/OuraStreamMapping.kt b/android/app/src/main/java/com/noop/data/OuraStreamMapping.kt index 7339dba0ad..b8dd31b331 100644 --- a/android/app/src/main/java/com/noop/data/OuraStreamMapping.kt +++ b/android/app/src/main/java/com/noop/data/OuraStreamMapping.kt @@ -127,7 +127,9 @@ object OuraStreamMapping { // PPG/perfusion signal (-9K to +11.7M in the same capture) - not SpO2 at all, so // blending it into `red` would corrupt any consumer that averages the stream with no // unit filter. Mirrors the Swift OuraStreamMapping twin. - if (ev.value.unit != "raw") continue + // The Android fork also persists 0x8b's phone-derived percentage (unit "r_pi_pct", + // OuraSpO2Ratio): same 85-100 % scale and the same `red` column, distinguishable by unit. + if (ev.value.unit != "raw" && ev.value.unit != com.noop.oura.OuraSpO2Channel.RATIO_PERCENT_UNIT) continue // The ring exposes ONE combined SpO2 reading (not separate red/ir channels): its // raw value goes in `red`; `ir` stays 0 (an unread channel, never a fabricated // second reading). `unit` carries the decoder's own scale tag so downstream never diff --git a/android/app/src/main/java/com/noop/oura/Decoders.kt b/android/app/src/main/java/com/noop/oura/Decoders.kt index d95b3c4ec8..5d8d28d306 100644 --- a/android/app/src/main/java/com/noop/oura/Decoders.kt +++ b/android/app/src/main/java/com/noop/oura/Decoders.kt @@ -301,6 +301,41 @@ object OuraDecoders { // MARK: - SpO2 per-sample (0x6F; s6.5) + /** + * Decode a `0x8b` spo2_r_pi_event (OURA_PROTOCOL.md s6.5.1): one header byte, then 3-byte samples of + * u16 BIG-endian R-ratio (÷ 16384) + u8 perfusion index. Each R becomes an SpO2 percentage through + * [OuraSpO2Ratio.percent] and is tagged [OuraSpO2Channel.RATIO_PERCENT_UNIT], so a derived value can + * never be mistaken for the firmware-computed 0x6F channel. The layout [oura-rs] is confirmed on real + * Ring 4 (`ORE_06`) captures: every payload is 1 + 3n bytes and the R-ratios sit at 0.53-0.77. Samples + * are one per second (4 per record at a ~4.5 s record cadence), so each carries its index like 0x6F. + * + * A sample whose perfusion-index byte is saturated (`0xFF`) is SKIPPED: in the first Ring 4 capture all 6 + * of 64 such samples carried the same R (0.766, ~93 %) while every other sample varied (0.53-0.89), the + * shape of a held / no-signal value rather than a reading. Inferred, not documented. A skipped sample + * still CONSUMES its position, so the survivors keep their true seconds (the 0x5D `00 00` pad rule). + */ + fun decodeSpO2RatioPi(rec: OuraRecord): List? { + val b = rec.payload + if (b.size < 4 || (b.size - 1) % 3 != 0) return null + val positions = (b.size - 1) / 3 + val out = ArrayList() + for (i in 0 until positions) { + val o = 1 + i * 3 + if (b[o + 2] == 0xFF) continue + val r = ((b[o] shl 8) or b[o + 1]) / 16384.0 + out.add( + OuraSpO2( + ringTimestamp = rec.ringTimestamp, + value = OuraSpO2Ratio.percent(r), + unit = OuraSpO2Channel.RATIO_PERCENT_UNIT, + index = i, + count = positions, + ), + ) + } + return out.ifEmpty { null } + } + /** * Decode the 0x6F spo2_event: byte6 bits [7:4]=SpO2 base/status field, [3:0]=status flag; then one * uint8 SpO2 value per second from byte7 onward (optional 0xFF terminator). Per OURA_PROTOCOL.md diff --git a/android/app/src/main/java/com/noop/oura/EventTags.kt b/android/app/src/main/java/com/noop/oura/EventTags.kt index f10ddf4c29..8a16314919 100644 --- a/android/app/src/main/java/com/noop/oura/EventTags.kt +++ b/android/app/src/main/java/com/noop/oura/EventTags.kt @@ -44,6 +44,7 @@ enum class OuraEventTag(val raw: Int) { // --- SpO2 (Tier A) --- SPO2_PER_SAMPLE(0x6F), // spo2_event per-second, OURA_PROTOCOL.md s6.5 SPO2_STABLE(0x7B), // spo2_stable_event (uint16 BIG-endian), OURA_PROTOCOL.md s6.6 + SPO2_R_PI(0x8B), // spo2_r_pi_event (R-ratio + perfusion index), OURA_PROTOCOL.md s6.5.1 SPO2_DC(0x77), // spo2_dc_event (sign-magnitude deltas), OURA_PROTOCOL.md s6.7 // --- Temperature (Tier A) --- @@ -130,6 +131,7 @@ enum class OuraEventTag(val raw: Int) { HRV_RMSSD -> "HRV_RMSSD" SPO2_PER_SAMPLE -> "SPO2_PER_SAMPLE" SPO2_STABLE -> "SPO2_STABLE" + SPO2_R_PI -> "SPO2_R_PI" SPO2_DC -> "SPO2_DC" TEMP -> "TEMP" TEMP_PERIOD -> "TEMP_PERIOD" @@ -158,10 +160,5 @@ enum class OuraEventTag(val raw: Int) { /** Map a `type` byte to its tag, or null when the byte is not in the dictionary (honest). */ fun fromRaw(raw: Int): OuraEventTag? = byRaw[raw] - - /** `spo2_r_pi_event` (OURA_PROTOCOL.md s6.5.1): SpO2 ratio-of-ratios + perfusion index. Deliberately - * NOT a dictionary entry — never received by NOOP, so no layout is validated; the history drain only - * logs its raw payloads as fixtures. */ - const val SPO2_R_PI_UNDECODED: Int = 0x8B } } diff --git a/android/app/src/main/java/com/noop/oura/OuraDriver.kt b/android/app/src/main/java/com/noop/oura/OuraDriver.kt index 898657ba57..e511ab1fd4 100644 --- a/android/app/src/main/java/com/noop/oura/OuraDriver.kt +++ b/android/app/src/main/java/com/noop/oura/OuraDriver.kt @@ -454,6 +454,8 @@ class OuraDriver( OuraDecoders.decodeSpO2Stable(record)?.let { listOf(OuraEvent.Spo2(it)) } ?: emptyList() OuraEventTag.SPO2_DC -> (OuraDecoders.decodeSpO2DC(record) ?: emptyList()).map { OuraEvent.Spo2(it) } + OuraEventTag.SPO2_R_PI -> + (OuraDecoders.decodeSpO2RatioPi(record) ?: emptyList()).map { OuraEvent.Spo2(it) } // --- Tier A: Temperature --- OuraEventTag.TEMP -> diff --git a/android/app/src/main/java/com/noop/oura/OuraEvents.kt b/android/app/src/main/java/com/noop/oura/OuraEvents.kt index 0f753630a8..57e65ee4d0 100644 --- a/android/app/src/main/java/com/noop/oura/OuraEvents.kt +++ b/android/app/src/main/java/com/noop/oura/OuraEvents.kt @@ -160,6 +160,9 @@ enum class OuraSpO2Channel { /** 0x77 — a raw DC perfusion magnitude. Not a percentage, and never stored as one. */ PERFUSION, + /** 0x8b — an SpO2 percentage DERIVED on the phone from the ring's R-ratio ([OuraSpO2Ratio]). */ + RATIO_PERCENTAGE, + /** * A channel whose scale is not pinned (0x7B's [STABLE_UNIT]), or a unit tag no decoder stamps (a case * variant, or a future tag). Named, never a percentage. @@ -175,6 +178,7 @@ enum class OuraSpO2Channel { val logLabel: String get() = when (this) { PERCENTAGE -> "SpO2 percentage" PERFUSION -> "SpO2 raw DC perfusion (NOT a percentage)" + RATIO_PERCENTAGE -> "SpO2 percentage estimated from the 0x8b R-ratio" UNKNOWN -> "SpO2 sample on an unrecognised channel (NOT known to be a percentage)" } @@ -185,6 +189,9 @@ enum class OuraSpO2Channel { /** The unit tag 0x77 stamps on its samples. */ const val PERFUSION_UNIT = "dc_raw" + /** The unit tag 0x8b's derived percentages carry (Android fork; see [OuraSpO2Ratio]). */ + const val RATIO_PERCENT_UNIT = "r_pi_pct" + /** * The unit tag 0x7B stamps on its sample. It deliberately resolves to UNKNOWN: the value is a * big-endian u16 whose scale no source pins (OURA_PROTOCOL.md s6.6), and the golden vector decodes @@ -203,6 +210,7 @@ enum class OuraSpO2Channel { fun forUnit(unit: String): OuraSpO2Channel = when (unit) { PERCENTAGE_UNIT -> PERCENTAGE PERFUSION_UNIT -> PERFUSION + RATIO_PERCENT_UNIT -> RATIO_PERCENTAGE else -> UNKNOWN } @@ -219,7 +227,7 @@ enum class OuraSpO2Channel { */ fun firstDecodedLogLine(value: Int, unit: String): String { val c = forUnit(unit) - val pct = if (c == PERCENTAGE) " %" else "" + val pct = if (c == PERCENTAGE || c == RATIO_PERCENTAGE) " %" else "" return "first ${c.logLabel} decoded (last night) - $value$pct (channel \"$unit\")" } } diff --git a/android/app/src/main/java/com/noop/oura/OuraSpO2Ratio.kt b/android/app/src/main/java/com/noop/oura/OuraSpO2Ratio.kt new file mode 100644 index 0000000000..48b504884d --- /dev/null +++ b/android/app/src/main/java/com/noop/oura/OuraSpO2Ratio.kt @@ -0,0 +1,21 @@ +package com.noop.oura + +import kotlin.math.roundToInt + +/** + * SpO2 from the ring's `0x8b` R-ratio (OURA_PROTOCOL.md s6.5.1): `SpO2(%) = a·r² + b·r + c`, clamped to + * [MIN_PERCENT]..[MAX_PERCENT]. The coefficients are the gen4/"oreo" hardware row cited by [open_oura-spo2] + * as interoperability facts (the "cooper" row differs by < 1 % on real data); a Ring 4 reports hardware + * `ORE_06`. Not a validated calibration: the result is an ESTIMATE, surfaced only through the SpO2 estimate + * display path and never written to `spo2Pct` or scored. + */ +object OuraSpO2Ratio { + const val COEF_A = -13.4 + const val COEF_B = -5.1 + const val COEF_C = 105.2 + const val MIN_PERCENT = 85.0 + const val MAX_PERCENT = 100.0 + + /** Whole-percent SpO2 for one R-ratio sample. */ + fun percent(r: Double): Int = (COEF_A * r * r + COEF_B * r + COEF_C).coerceIn(MIN_PERCENT, MAX_PERCENT).roundToInt() +} diff --git a/android/app/src/test/java/com/noop/oura/OuraSpO2RatioTest.kt b/android/app/src/test/java/com/noop/oura/OuraSpO2RatioTest.kt new file mode 100644 index 0000000000..af284ecc71 --- /dev/null +++ b/android/app/src/test/java/com/noop/oura/OuraSpO2RatioTest.kt @@ -0,0 +1,72 @@ +package com.noop.oura + +import com.noop.data.OuraStreamMapping +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The `0x8b` spo2_r_pi decode (OURA_PROTOCOL.md s6.5.1). Fixtures are verbatim payloads from the first + * Ring 4 (`ORE_06`) capture after SpO2 was enabled (strap log 2026-09-27); expected percentages are the + * gen4 quadratic, clamped 85-100 and rounded. + */ +class OuraSpO2RatioTest { + private fun rec(hex: String, rt: Long = 44_340_307L) = + OuraRecord(type = 0x8B, ringTimestamp = rt, payload = hex.chunked(2).map { it.toInt(16) }.toIntArray()) + + @Test + fun decodesRealRing4Payload() { + val out = OuraDecoders.decodeSpO2RatioPi(rec("0023619125d576248772235a72"))!! + assertEquals(listOf(98, 98, 98, 98), out.map { it.value }) + assertEquals(listOf(0, 1, 2, 3), out.map { it.index }) + assertTrue(out.all { it.count == 4 && it.unit == OuraSpO2Channel.RATIO_PERCENT_UNIT }) + assertEquals( + listOf(96, 96, 94, 93), + OuraDecoders.decodeSpO2RatioPi(rec("002a6a932a6b922faff2310aff".replace("310aff", "310a00")))!!.map { it.value }, + ) + } + + @Test + fun saturatedPerfusionSampleIsSkippedButKeepsItsSecond() { + // First sample carries PI 0xFF (the held ~0.766 R); the rest are real. + val out = OuraDecoders.decodeSpO2RatioPi(rec("003104ff2477c7261ac622cb6b"))!! + assertEquals(listOf(98, 97, 98), out.map { it.value }) + assertEquals(listOf(1, 2, 3), out.map { it.index }) + assertTrue(out.all { it.count == 4 }) + // A record that is ALL held values yields nothing. + assertNull(OuraDecoders.decodeSpO2RatioPi(rec("00310bff310aff310aff310aff"))) + } + + @Test + fun malformedPayloadsDecodeToNull() { + assertNull(OuraDecoders.decodeSpO2RatioPi(rec("00"))) + assertNull(OuraDecoders.decodeSpO2RatioPi(rec("0031a28832"))) // not 1 + 3n + // The smallest well-formed record: one header byte + one sample. + assertEquals(listOf(93), OuraDecoders.decodeSpO2RatioPi(rec("0031a288"))!!.map { it.value }) + } + + @Test + fun percentIsClampedToTheDocumentedRange() { + assertEquals(100, OuraSpO2Ratio.percent(0.0)) + assertEquals(85, OuraSpO2Ratio.percent(1.5)) + assertEquals(97, OuraSpO2Ratio.percent(0.6)) // 105.2 - 3.06 - 4.824 = 97.316 + } + + @Test + fun driverRoutesTheTagAndMappingPersistsDerivedPercentages() { + val d = OuraDriver(ringGen = OuraRingGen.GEN4, authKey = IntArray(16) { it }) + val events = d.ingest(rec("0023619125d576248772235a72")) + assertEquals(4, events.size) + assertTrue(events.all { it is OuraEvent.Spo2 }) + val streams = OuraStreamMapping.streams(events) { 1_790_000_000 } + assertEquals(listOf(1_789_999_997, 1_789_999_998, 1_789_999_999, 1_790_000_000), streams.spo2.map { it.ts }) + assertTrue(streams.spo2.all { it.red == 98 && it.ir == 0 && it.unit == OuraSpO2Channel.RATIO_PERCENT_UNIT }) + } + + @Test + fun channelNamesTheDerivedPercentage() { + assertEquals(OuraSpO2Channel.RATIO_PERCENTAGE, OuraSpO2Channel.forUnit(OuraSpO2Channel.RATIO_PERCENT_UNIT)) + assertEquals(OuraSpO2Channel.PERCENTAGE, OuraSpO2Channel.forUnit(OuraSpO2Channel.PERCENTAGE_UNIT)) + } +} diff --git a/docs/OURA_PROTOCOL.md b/docs/OURA_PROTOCOL.md index 600f6c7212..9ca2d86318 100644 --- a/docs/OURA_PROTOCOL.md +++ b/docs/OURA_PROTOCOL.md @@ -681,7 +681,7 @@ like its sibling banked streams (`.hrv`/`.temp`/`.spo2`/`.sleepPhase`) — the f for this signal, not a validated calibration. More nights (ideally spanning the known overshoot swing) are needed before any offset is defensible enough to write to `spo2Pct`. -### 6.5.1 SpO2 ratio-of-ratios - `0x8b` `spo2_r_pi_event` — **NOT OBSERVED in NOOP captures** +### 6.5.1 SpO2 ratio-of-ratios - `0x8b` `spo2_r_pi_event` — **OBSERVED on Ring 4 once SpO2 mode is automatic (Android fork, 2026-09-27)** - Carries the raw **ratio-of-ratios `r`** plus a **perfusion index `pi`** (quality parameter). This is the input the official app converts to a percentage, NOT a percentage itself. [open_oura-spo2] - **Conversion (app-side, `libecore` `EcoreWrapper.nativeCalculateSpO2Simple`):** @@ -695,7 +695,8 @@ like its sibling banked streams (`.hrv`/`.temp`/`.spo2`/`.sleepPhase`) — the f The two sets differ by <1 % on test data, so the mapping is robust to picking the wrong one; the Ring 5 mapping is unconfirmed in app 7.18. A naive generic `110 − 25·r` reads ~91 % against a calibrated ~93.4 % — i.e. materially worse. [open_oura-spo2] -- **NOOP has NEVER received `0x8b`**: 0 occurrences across every capture checked (vs 5,585 × `0x6F` and +- **Ring 4 capture (Android fork, 2026-09-27):** after the Test Centre feature-mode write set SpO2 to automatic on a server-gated ring (§7.5), the first night produced **5,236** `0x8b` records (vs 0 before) at a ~4.5 s record cadence. Every logged payload is `1 + 3n` bytes (one header byte `00`, then `n = 4` samples of u16 **big-endian** R ÷ 16384 + u8 PI), matching [oura-rs]; R sat at 0.53–0.89 and the gen4 quadratic above gives 90–99 %. **Android decodes it** (`OuraDecoders.decodeSpO2RatioPi` → `OuraSpO2Ratio.percent`, clamped 85–100, rounded) into per-second `spo2Sample` rows tagged `r_pi_pct`, laid backward from the record time like `0x6F`; they feed only the SpO2 estimate display path (never `spo2Pct`, never scored). A sample whose PI byte is `0xFF` is skipped (keeping its second): all 6 of 64 such samples in the capture carried the same R (0.766), the shape of a held / no-signal value — inferred, not documented. +- **Before that capture, NOOP had never received `0x8b`**: 0 occurrences across every capture checked (vs 5,585 × `0x6F` and 21,706 × `0x77` in the same files), and it is absent from `OuraEventTag`. Whether that is generation-specific (the published coefficients name Gen4/Oreo and Cooper), server-flag gated like SpO2 itself (§7.1), or simply not emitted on this Gen 3 is **unknown**. Worth a targeted check if a properly From 6bf92cabd75bc542a9766418b4ed9bdbed10f7db Mon Sep 17 00:00:00 2001 From: llmassisted Date: Sun, 27 Sep 2026 17:47:45 -0400 Subject: [PATCH 2/2] fix(oura): store 0x8b SpO2 records verbatim instead of per-second rows Review of the first version found two reproduced problems with writing derived per-second percentages into spo2Sample: 1. spo2Sample has no unit column (StreamPersistence drops `unit`), so the r_pi_pct tag was lost on save and derived rows became indistinguishable from the firmware 0x6F percentage. 2. Records arrive 2.2-4.6 s apart yet carry 4 samples, so one-second spacing is wrong: on the captured records 5-8 of 58 samples collided on the (deviceId, ts) key and were silently dropped. Now each 0x8b record is stored verbatim as ONE OURA_SPO2_RPI event row (raw R u16s + PI bytes, record-level ts: >= 2.2 s apart, so no collisions; the event kind keeps the source). The Oura SpO2 estimate reads those rows, converts at read time (OuraSpO2Ratio), skips held-PI samples, and logs the night's low / median / mean / high (OuraSpO2Nightly); nights without 0x8b fall back to the 0x6F ceiling@100 path. The derived-percentage channel and its unit tag are removed. Tests drive the real 03:57 capture through driver, mapping and StreamPersistence.toBatch at four anchor phases. versionCode 541. Co-Authored-By: Claude Opus 5.5 (1M context) --- Tools/parity_dispositions.json | 64 +++++++++-- Tools/parity_twin_map.json | 12 +- android/app/build.gradle.kts | 2 +- .../com/noop/analytics/IntelligenceEngine.kt | 12 +- .../com/noop/analytics/OuraSpO2Nightly.kt | 55 +++++++++ .../main/java/com/noop/ble/OuraLiveSource.kt | 5 + .../java/com/noop/data/OuraStreamMapping.kt | 24 +++- .../src/main/java/com/noop/oura/Decoders.kt | 40 +++---- .../src/main/java/com/noop/oura/OuraDriver.kt | 2 +- .../src/main/java/com/noop/oura/OuraEvents.kt | 23 ++-- .../java/com/noop/oura/OuraSpO2RatioTest.kt | 107 +++++++++++------- docs/OURA_PROTOCOL.md | 2 +- 12 files changed, 252 insertions(+), 96 deletions(-) create mode 100644 android/app/src/main/java/com/noop/analytics/OuraSpO2Nightly.kt diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index c4228fa482..60a2d33658 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -417,14 +417,6 @@ "platform": "kotlin", "rationale": "Kotlin twin of the Swift OuraActivityEstimator (Packages/OuraProtocol/.../ActivityEstimate.swift). Byte-identical output is pinned by OuraActivityEstimatorTest against the verbatim stdout of the Swift source compiled standalone over 300 generated inputs; the ledger cannot pair it by name because both platforms overload `estimate`." }, - { - "type": "platform_specific", - "kind": "add-unpaired-constant", - "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/OuraEvents.kt::RATIO_PERCENT_UNIT", - "identity_sha256": "45a61921ac2064b62ce539181f06665077c4761da490e2994ce3bd0e3d4e7d8a", - "platform": "kotlin", - "rationale": "Android-only decode of the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1) into a phone-derived SpO2 percentage (gen4 quadratic cited from [open_oura-spo2]), tagged r_pi_pct and surfaced only through the SpO2 estimate display path. The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." - }, { "type": "platform_specific", "kind": "add-unpaired-constant", @@ -488,6 +480,62 @@ "identity_sha256": "c513f2be9fff544a5dbee95f54f28458bec979b5a3c57292191ac3f88f1970a7", "platform": "kotlin", "rationale": "Android-only decode of the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1) into a phone-derived SpO2 percentage (gen4 quadratic cited from [open_oura-spo2]), tagged r_pi_pct and surfaced only through the SpO2 estimate display path. The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/OuraSpO2Nightly.kt::PI_SATURATED", + "identity_sha256": "feee8e608022c383aeefe7a840e7497f3640db2617bbf6ce88d4b69abbd0ccce", + "platform": "kotlin", + "rationale": "Android-only storage and nightly estimate for the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1): records are kept verbatim as OURA_SPO2_RPI events and converted at read time into the Oura SpO2 estimate (never spo2Pct, never scored). The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/OuraStreamMapping.kt::EVENT_SPO2_RPI", + "identity_sha256": "26f18c6f9913d8c83e972c3634a1606a0afcbeb5d597b4a648261fbe03d45c80", + "platform": "kotlin", + "rationale": "Android-only storage and nightly estimate for the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1): records are kept verbatim as OURA_SPO2_RPI events and converted at read time into the Oura SpO2 estimate (never spo2Pct, never scored). The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-file", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/OuraSpO2Nightly.kt", + "identity_sha256": "a16ec04e9d1b7686c3124bc55776e0e118509f00793dbc90d02c3e08b26f088f", + "platform": "kotlin", + "rationale": "Android-only storage and nightly estimate for the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1): records are kept verbatim as OURA_SPO2_RPI events and converted at read time into the Oura SpO2 estimate (never spo2Pct, never scored). The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::spo2CandidateMean/7#1", + "identity_sha256": "e841530d08842be977ebce724b46f9f262179fbb267da49ed6a044ca7d491fcd", + "platform": "kotlin", + "rationale": "Android-only storage and nightly estimate for the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1): records are kept verbatim as OURA_SPO2_RPI events and converted at read time into the Oura SpO2 estimate (never spo2Pct, never scored). The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/OuraSpO2Nightly.kt::displayMean/1#1", + "identity_sha256": "0621d0de852cbe823ae18586bf47c6f051ea174f233bf5ca4f86184ef9b73b70", + "platform": "kotlin", + "rationale": "Android-only storage and nightly estimate for the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1): records are kept verbatim as OURA_SPO2_RPI events and converted at read time into the Oura SpO2 estimate (never spo2Pct, never scored). The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/OuraSpO2Nightly.kt::line/1#1", + "identity_sha256": "93eeafaac3d4b2801eb6d7b3550a676417f4d1a1796d24a4090f993397dca6f6", + "platform": "kotlin", + "rationale": "Android-only storage and nightly estimate for the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1): records are kept verbatim as OURA_SPO2_RPI events and converted at read time into the Oura SpO2 estimate (never spo2Pct, never scored). The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/OuraSpO2Nightly.kt::summary/2#1", + "identity_sha256": "a77f37236a04c46f1900b9279980bd468a5ec3a357e02e9d2960a677cc81e21c", + "platform": "kotlin", + "rationale": "Android-only storage and nightly estimate for the Ring 4 0x8b spo2_r_pi stream (OURA_PROTOCOL.md s6.5.1): records are kept verbatim as OURA_SPO2_RPI events and converted at read time into the Oura SpO2 estimate (never spo2Pct, never scored). The fork's SpO2 feature-mode enable is Android-only, so only this platform receives 0x8b." } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 627e0edf53..1a32fed4e5 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -18,17 +18,17 @@ ] }, "authority": { - "files": {"count": 507, "sha256": "6a87e93c099553fef09c336bb353e370da21b31c097ec5bb81cb57c036d70655"}, - "functions": {"count": 4505, "sha256": "9265257d682dc5c290ac16b95aa18e9591566c0f27aa06a5025895c416c2307a"}, + "files": {"count": 508, "sha256": "b7ab504f627dff2fb8c43c5021fd3ab02c75106f76ed9c6d15cd36e90cfce80a"}, + "functions": {"count": 4508, "sha256": "3ce3f292737744b7395a8c8e1225db4dd09e7d5c5ef18a216cce470dad5113c2"}, "properties": {"count": 464, "sha256": "85338b70168a36af785255eb8959a41272cc02334529c2e3ef2a7d18444c7ef7"}, - "constants": {"count": 1973, "sha256": "8f2eaf7572fd4dcb89505cc1745e956e87ac431aec03f82adcf3dd7d4441b72d"}, + "constants": {"count": 1974, "sha256": "6126e173cc02eb2385ceb7332b349f1522f9dcaad7ba786b0c58ffca3240f9dc"}, "file_pairs": {"count": 74, "sha256": "1023813dc72dbe4122281d5bae27ac2b7a657dae5904cb5fd603032928199f94"}, "function_pairs": {"count": 189, "sha256": "11df8bf87b16b535aeddd0c00471f87431236fd0b059484c07f6d5939ace9732"}, "property_pairs": {"count": 149, "sha256": "8a62616eca6270e3d8899c47cfe8ae93691f16846ed1959e19a762827b6ec185"}, "constant_pairs": {"count": 681, "sha256": "3484c8096ac8d2832e75f18900c052a19bfb7d92cbc2fa7fdb6137d8fa46ebf6"}, - "unpaired_files": {"count": 381, "sha256": "2efab44c14d7e749c9b48a1521b6d3b5dfd6ff856540bb22ffde6f02efe75732"}, - "unpaired_functions": {"count": 4139, "sha256": "e7397024ec4a2423a9f51802dea570a1ea06ffd7230ca8c52893409cfddbbdd4"}, + "unpaired_files": {"count": 382, "sha256": "81e8923060fd20e5a887308d37750f5112f3804f15560833d4c2856f89e2871f"}, + "unpaired_functions": {"count": 4142, "sha256": "b583ba53e9df860dd4a91e502cbb557bb506ae398c5a8a704770cded8968f206"}, "unpaired_properties": {"count": 166, "sha256": "280d96f0905fd623dfb66af86c99c3e82409b126a45aaf9e008eb8f6cf04671a"}, - "unpaired_constants": {"count": 611, "sha256": "5c25d5051093b9351502ce152f7124a262b285292e838331448576bf096a76b5"} + "unpaired_constants": {"count": 612, "sha256": "c6c27a5ea84b9b0ac0e13379f68fa5625c95dc5e45652e7658dcdd439d53c777"} } } diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 75a656976c..bcccd557e2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -26,7 +26,7 @@ android { applicationId = "com.noop.whoop" minSdk = 26 targetSdk = 34 - versionCode = 540 + versionCode = 541 versionName = "11.8.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index 9e91e02dec..8e143c7b37 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -297,8 +297,18 @@ object IntelligenceEngine { spo2: List, from: Long, to: Long, + diag: (String) -> Unit, ): Int? { if (DeviceBrandCatalog.isOura(owner)) { + // Ring 4: the verbatim 0x8b R-ratio records (OURA_SPO2_RPI) when the night has any; otherwise the + // firmware 0x6F percentages through the ceiling@100 transform (Gen 3). + val ratioRows = runCatching { + repo.eventsByKind(owner, com.noop.data.OuraStreamMapping.EVENT_SPO2_RPI, from, to, STREAM_LIMIT) + }.getOrDefault(emptyList()) + OuraSpO2Nightly.summary(sessions, ratioRows)?.let { + diag(OuraSpO2Nightly.line(it)) + return OuraSpO2Nightly.displayMean(it) + } return AnalyticsEngine.nightlySpo2CeilingMean(sessions, spo2)?.first } val auxSamples = repo.v18AuxSamples(owner, from, to, STREAM_LIMIT) @@ -1399,7 +1409,7 @@ object IntelligenceEngine { // bytes of the JVM's 64 KB per-method ceiling, and this block plus the #1575 trace recorders // put the JaCoCo-instrumented size 30 bytes OVER the budget #1524 guards. Neither change // exceeded it alone — only together, which no single PR's CI could see. - spo2CandidateMean(repo, owner, res.sleepSessions, spo2, from, to)?.let { + spo2CandidateMean(repo, owner, res.sleepSessions, spo2, from, to, ::dayDiag)?.let { spo2CandidateByDay[res.daily.day] = it } } diff --git a/android/app/src/main/java/com/noop/analytics/OuraSpO2Nightly.kt b/android/app/src/main/java/com/noop/analytics/OuraSpO2Nightly.kt new file mode 100644 index 0000000000..3d8b9206b4 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/OuraSpO2Nightly.kt @@ -0,0 +1,55 @@ +package com.noop.analytics + +import com.noop.data.EventRow +import com.noop.data.OuraStreamMapping +import com.noop.oura.OuraSpO2Ratio +import kotlin.math.roundToInt +import org.json.JSONObject + +/** + * Nightly SpO2 ESTIMATE from the Ring 4's verbatim `0x8b` records (OURA_SPO2_RPI events, OURA_PROTOCOL.md + * s6.5.1). A record counts when its anchored time falls inside a detected sleep session; every sample in it is + * converted with [OuraSpO2Ratio.percent]. A nightly low / median / mean / high needs no per-second timing, so + * the unpinned within-record spacing does not matter here. Diagnostic estimate only: never `spo2Pct`, never + * scored. Android-only. + */ +internal object OuraSpO2Nightly { + /** Samples used, and the night's low / median / mean / high in whole percent (median and mean unrounded). */ + data class Summary(val samples: Int, val low: Int, val median: Double, val mean: Double, val high: Int) + + /** + * Perfusion-index byte marking a held / no-signal sample: in the first Ring 4 capture all 6 of 64 such + * samples carried the same R (0.766, ~93 %) while every other sample varied. Inferred, not documented. + */ + const val PI_SATURATED = 0xFF + + fun summary(sessions: List, rows: List): Summary? { + if (sessions.isEmpty() || rows.isEmpty()) return null + val values = ArrayList() + for (row in rows) { + if (row.kind != OuraStreamMapping.EVENT_SPO2_RPI) continue + if (sessions.none { row.ts in it.start..it.end }) continue + val o = runCatching { JSONObject(row.payloadJSON) }.getOrNull() ?: continue + val r = o.optJSONArray("r_x16384") ?: continue + val pi = o.optJSONArray("pi") ?: continue + if (r.length() != pi.length()) continue + for (i in 0 until r.length()) { + if (pi.getInt(i) == PI_SATURATED) continue + values.add(OuraSpO2Ratio.percent(r.getInt(i) / 16384.0)) + } + } + if (values.isEmpty()) return null + values.sort() + val n = values.size + val median = if (n % 2 == 1) values[n / 2].toDouble() else (values[n / 2 - 1] + values[n / 2]) / 2.0 + return Summary(n, values.first(), median, values.average(), values.last()) + } + + /** The strap-log line for one night's summary (counts and percentages only). */ + fun line(s: Summary): String = + "spo2 r-pi night samples=${s.samples} low=${s.low} median=${"%.1f".format(java.util.Locale.ROOT, s.median)} " + + "mean=${"%.1f".format(java.util.Locale.ROOT, s.mean)} high=${s.high} (0x8b estimate, not scored)" + + /** The whole-percent value the SpO2 estimate tile shows. */ + fun displayMean(s: Summary): Int = s.mean.roundToInt() +} diff --git a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt index dc07c7ec94..e40034fb8f 100644 --- a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt +++ b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt @@ -2687,6 +2687,11 @@ class OuraLiveSource( ) enqueueAnchoredOrPark(e, v.ringTimestamp, d) } + is OuraEvent.Spo2Ratio -> { + // 0x8b SpO2 R-ratio record (history-only): stored verbatim as ONE OURA_SPO2_RPI event per + // record (OuraStreamMapping); the SpO2 estimate converts it at read time. Never scored. + enqueueAnchoredOrPark(e, e.value.ringTimestamp, d) + } is OuraEvent.MotionVectorEvent -> { // 0x47 averaged accel vector (Tier-A). Persisted as an OURA_MOTION event (same event-table // path as OURA_HRV / OURA_SLEEP_PHASE — see OuraStreamMapping), AND appended to the raw diff --git a/android/app/src/main/java/com/noop/data/OuraStreamMapping.kt b/android/app/src/main/java/com/noop/data/OuraStreamMapping.kt index b8dd31b331..e3411b2790 100644 --- a/android/app/src/main/java/com/noop/data/OuraStreamMapping.kt +++ b/android/app/src/main/java/com/noop/data/OuraStreamMapping.kt @@ -43,6 +43,9 @@ object OuraStreamMapping { /** The event `kind` for a decoded 0x47 motion window (activity instrumentation). Must match Swift. */ const val EVENT_MOTION = "OURA_MOTION" + /** The event `kind` for a verbatim 0x8b SpO2 R-ratio record (Android fork, OURA_PROTOCOL.md s6.5.1). */ + const val EVENT_SPO2_RPI = "OURA_SPO2_RPI" + /** The event `kind` for a decoded 0x50 activity/MET record (Tier-B activity ESTIMATE, never scored). */ const val EVENT_MET = "OURA_MET" @@ -127,9 +130,7 @@ object OuraStreamMapping { // PPG/perfusion signal (-9K to +11.7M in the same capture) - not SpO2 at all, so // blending it into `red` would corrupt any consumer that averages the stream with no // unit filter. Mirrors the Swift OuraStreamMapping twin. - // The Android fork also persists 0x8b's phone-derived percentage (unit "r_pi_pct", - // OuraSpO2Ratio): same 85-100 % scale and the same `red` column, distinguishable by unit. - if (ev.value.unit != "raw" && ev.value.unit != com.noop.oura.OuraSpO2Channel.RATIO_PERCENT_UNIT) continue + if (ev.value.unit != "raw") continue // The ring exposes ONE combined SpO2 reading (not separate red/ir channels): its // raw value goes in `red`; `ir` stays 0 (an unread channel, never a fabricated // second reading). `unit` carries the decoder's own scale tag so downstream never @@ -225,6 +226,23 @@ object OuraStreamMapping { out.events.add(WhoopEvent(ts = ts, kind = EVENT_MOTION, payload = payload)) } + is OuraEvent.Spo2Ratio -> { + // 0x8b → ONE OURA_SPO2_RPI event per record at the record's anchored ts, carrying the raw + // R u16s and PI bytes verbatim. Record-level on purpose: records are >= 2.2 s apart, so + // their ts never collide, whereas per-sample seconds did (4 samples per 2.2-4.6 s record, + // spacing unpinned). The event kind also keeps this derived source apart from the 0x6F + // firmware percentage, which `spo2Sample` (no unit column) could not. Never scored; the + // SpO2 estimate converts at read time (OuraSpO2Ratio / OuraSpO2Nightly). + val ts = anchor(ev.value.ringTimestamp) ?: continue + out.events.add( + WhoopEvent( + ts = ts, + kind = EVENT_SPO2_RPI, + payload = linkedMapOf("r_x16384" to ev.value.rX16384, "pi" to ev.value.pi), + ), + ) + } + is OuraEvent.ActivityInfo -> { // 0x50 activity record → one OURA_MET event at the record's anchored ts, carrying its // MET series VERBATIM as integer tenths (every decoded value is a multiple of 0.1, so diff --git a/android/app/src/main/java/com/noop/oura/Decoders.kt b/android/app/src/main/java/com/noop/oura/Decoders.kt index 5d8d28d306..2ac21a6cfd 100644 --- a/android/app/src/main/java/com/noop/oura/Decoders.kt +++ b/android/app/src/main/java/com/noop/oura/Decoders.kt @@ -302,38 +302,24 @@ object OuraDecoders { // MARK: - SpO2 per-sample (0x6F; s6.5) /** - * Decode a `0x8b` spo2_r_pi_event (OURA_PROTOCOL.md s6.5.1): one header byte, then 3-byte samples of - * u16 BIG-endian R-ratio (÷ 16384) + u8 perfusion index. Each R becomes an SpO2 percentage through - * [OuraSpO2Ratio.percent] and is tagged [OuraSpO2Channel.RATIO_PERCENT_UNIT], so a derived value can - * never be mistaken for the firmware-computed 0x6F channel. The layout [oura-rs] is confirmed on real - * Ring 4 (`ORE_06`) captures: every payload is 1 + 3n bytes and the R-ratios sit at 0.53-0.77. Samples - * are one per second (4 per record at a ~4.5 s record cadence), so each carries its index like 0x6F. - * - * A sample whose perfusion-index byte is saturated (`0xFF`) is SKIPPED: in the first Ring 4 capture all 6 - * of 64 such samples carried the same R (0.766, ~93 %) while every other sample varied (0.53-0.89), the - * shape of a held / no-signal value rather than a reading. Inferred, not documented. A skipped sample - * still CONSUMES its position, so the survivors keep their true seconds (the 0x5D `00 00` pad rule). + * Decode a `0x8b` spo2_r_pi_event (OURA_PROTOCOL.md s6.5.1) VERBATIM: one header byte, then 3-byte samples + * of u16 BIG-endian R-ratio (raw; R = value / 16384) + u8 perfusion index. The layout [oura-rs] is confirmed + * on real Ring 4 (`ORE_06`) captures (every payload 1 + 3n bytes, R 0.53-0.89). Nothing is converted or + * filtered here: the record is stored whole (OURA_SPO2_RPI) and [OuraSpO2Ratio] derives percentages at read + * time, so a later timing or calibration finding can re-read every stored night. Null when malformed. */ - fun decodeSpO2RatioPi(rec: OuraRecord): List? { + fun decodeSpO2RatioPi(rec: OuraRecord): OuraSpO2RatioRecord? { val b = rec.payload if (b.size < 4 || (b.size - 1) % 3 != 0) return null - val positions = (b.size - 1) / 3 - val out = ArrayList() - for (i in 0 until positions) { + val n = (b.size - 1) / 3 + val r = ArrayList(n) + val pi = ArrayList(n) + for (i in 0 until n) { val o = 1 + i * 3 - if (b[o + 2] == 0xFF) continue - val r = ((b[o] shl 8) or b[o + 1]) / 16384.0 - out.add( - OuraSpO2( - ringTimestamp = rec.ringTimestamp, - value = OuraSpO2Ratio.percent(r), - unit = OuraSpO2Channel.RATIO_PERCENT_UNIT, - index = i, - count = positions, - ), - ) + r.add(((b[o] and 0xFF) shl 8) or (b[o + 1] and 0xFF)) + pi.add(b[o + 2] and 0xFF) } - return out.ifEmpty { null } + return OuraSpO2RatioRecord(rec.ringTimestamp, r, pi) } /** diff --git a/android/app/src/main/java/com/noop/oura/OuraDriver.kt b/android/app/src/main/java/com/noop/oura/OuraDriver.kt index e511ab1fd4..f6c12e5922 100644 --- a/android/app/src/main/java/com/noop/oura/OuraDriver.kt +++ b/android/app/src/main/java/com/noop/oura/OuraDriver.kt @@ -455,7 +455,7 @@ class OuraDriver( OuraEventTag.SPO2_DC -> (OuraDecoders.decodeSpO2DC(record) ?: emptyList()).map { OuraEvent.Spo2(it) } OuraEventTag.SPO2_R_PI -> - (OuraDecoders.decodeSpO2RatioPi(record) ?: emptyList()).map { OuraEvent.Spo2(it) } + OuraDecoders.decodeSpO2RatioPi(record)?.let { listOf(OuraEvent.Spo2Ratio(it)) } ?: emptyList() // --- Tier A: Temperature --- OuraEventTag.TEMP -> diff --git a/android/app/src/main/java/com/noop/oura/OuraEvents.kt b/android/app/src/main/java/com/noop/oura/OuraEvents.kt index 57e65ee4d0..97c22da4e6 100644 --- a/android/app/src/main/java/com/noop/oura/OuraEvents.kt +++ b/android/app/src/main/java/com/noop/oura/OuraEvents.kt @@ -160,9 +160,6 @@ enum class OuraSpO2Channel { /** 0x77 — a raw DC perfusion magnitude. Not a percentage, and never stored as one. */ PERFUSION, - /** 0x8b — an SpO2 percentage DERIVED on the phone from the ring's R-ratio ([OuraSpO2Ratio]). */ - RATIO_PERCENTAGE, - /** * A channel whose scale is not pinned (0x7B's [STABLE_UNIT]), or a unit tag no decoder stamps (a case * variant, or a future tag). Named, never a percentage. @@ -178,7 +175,6 @@ enum class OuraSpO2Channel { val logLabel: String get() = when (this) { PERCENTAGE -> "SpO2 percentage" PERFUSION -> "SpO2 raw DC perfusion (NOT a percentage)" - RATIO_PERCENTAGE -> "SpO2 percentage estimated from the 0x8b R-ratio" UNKNOWN -> "SpO2 sample on an unrecognised channel (NOT known to be a percentage)" } @@ -189,9 +185,6 @@ enum class OuraSpO2Channel { /** The unit tag 0x77 stamps on its samples. */ const val PERFUSION_UNIT = "dc_raw" - /** The unit tag 0x8b's derived percentages carry (Android fork; see [OuraSpO2Ratio]). */ - const val RATIO_PERCENT_UNIT = "r_pi_pct" - /** * The unit tag 0x7B stamps on its sample. It deliberately resolves to UNKNOWN: the value is a * big-endian u16 whose scale no source pins (OURA_PROTOCOL.md s6.6), and the golden vector decodes @@ -210,7 +203,6 @@ enum class OuraSpO2Channel { fun forUnit(unit: String): OuraSpO2Channel = when (unit) { PERCENTAGE_UNIT -> PERCENTAGE PERFUSION_UNIT -> PERFUSION - RATIO_PERCENT_UNIT -> RATIO_PERCENTAGE else -> UNKNOWN } @@ -227,7 +219,7 @@ enum class OuraSpO2Channel { */ fun firstDecodedLogLine(value: Int, unit: String): String { val c = forUnit(unit) - val pct = if (c == PERCENTAGE || c == RATIO_PERCENTAGE) " %" else "" + val pct = if (c == PERCENTAGE) " %" else "" return "first ${c.logLabel} decoded (last night) - $value$pct (channel \"$unit\")" } } @@ -344,6 +336,15 @@ data class OuraFeatureStatus( */ data class OuraFeatureModeReply(val feature: Int, val status: Int) +/** + * One `0x8b` spo2_r_pi record (OURA_PROTOCOL.md s6.5.1), kept VERBATIM: per-sample R-ratio as the raw + * u16 (R = [rX16384] / 16384) and the raw perfusion-index byte [pi]. Deliberately NOT turned into per-second + * percentages at decode time: the within-record sample spacing is unpinned (records arrive 2.2-4.6 s apart + * yet carry 4 samples), so a per-second key would collide, and a derived percentage in `spo2Sample` would + * be indistinguishable from the firmware 0x6F channel once stored. [OuraSpO2Ratio] converts at read time. + */ +data class OuraSpO2RatioRecord(val ringTimestamp: Long, val rX16384: List, val pi: List) + /** A UTC anchor / time-sync event (OURA_PROTOCOL.md s6.11): epoch ms + timezone offset seconds. */ data class OuraTimeSync(val ringTimestamp: Long, val epochMs: Long, val tzOffsetSeconds: Int) @@ -536,6 +537,9 @@ sealed class OuraEvent { */ data class ActivityInfo(val value: OuraActivityInfo) : OuraEvent() + /** A verbatim `0x8b` SpO2 R-ratio record (see [OuraSpO2RatioRecord]); stored as an OURA_SPO2_RPI event. */ + data class Spo2Ratio(val value: OuraSpO2RatioRecord) : OuraEvent() + /** * A decoded `0x7E`/`0x7F` real_steps_features record (14 unpacked fields). Still Tier-B (see * [OuraRealStepsFields] doc) - split out of the raw-bytes [TierB] wrapper for the same reason @@ -571,6 +575,7 @@ sealed class OuraEvent { is Ibi -> value.ringTimestamp is Hrv -> value.ringTimestamp is Spo2 -> value.ringTimestamp + is Spo2Ratio -> value.ringTimestamp is Temp -> value.ringTimestamp is Battery -> null is SleepPhaseEvent -> value.ringTimestamp diff --git a/android/app/src/test/java/com/noop/oura/OuraSpO2RatioTest.kt b/android/app/src/test/java/com/noop/oura/OuraSpO2RatioTest.kt index af284ecc71..cc7ad11a25 100644 --- a/android/app/src/test/java/com/noop/oura/OuraSpO2RatioTest.kt +++ b/android/app/src/test/java/com/noop/oura/OuraSpO2RatioTest.kt @@ -1,72 +1,101 @@ package com.noop.oura +import com.noop.analytics.DetectedSleep +import com.noop.analytics.OuraSpO2Nightly +import com.noop.data.EventRow import com.noop.data.OuraStreamMapping +import com.noop.data.StreamPersistence import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** - * The `0x8b` spo2_r_pi decode (OURA_PROTOCOL.md s6.5.1). Fixtures are verbatim payloads from the first - * Ring 4 (`ORE_06`) capture after SpO2 was enabled (strap log 2026-09-27); expected percentages are the - * gen4 quadratic, clamped 85-100 and rounded. + * The `0x8b` spo2_r_pi path (OURA_PROTOCOL.md s6.5.1): verbatim decode → ONE OURA_SPO2_RPI event per record → + * the stored row → the nightly estimate. Fixtures are verbatim payloads from the first Ring 4 (`ORE_06`) + * capture after SpO2 was enabled (strap log 2026-09-27), with their real ring-times. */ class OuraSpO2RatioTest { private fun rec(hex: String, rt: Long = 44_340_307L) = OuraRecord(type = 0x8B, ringTimestamp = rt, payload = hex.chunked(2).map { it.toInt(16) }.toIntArray()) - @Test - fun decodesRealRing4Payload() { - val out = OuraDecoders.decodeSpO2RatioPi(rec("0023619125d576248772235a72"))!! - assertEquals(listOf(98, 98, 98, 98), out.map { it.value }) - assertEquals(listOf(0, 1, 2, 3), out.map { it.index }) - assertTrue(out.all { it.count == 4 && it.unit == OuraSpO2Channel.RATIO_PERCENT_UNIT }) - assertEquals( - listOf(96, 96, 94, 93), - OuraDecoders.decodeSpO2RatioPi(rec("002a6a932a6b922faff2310aff".replace("310aff", "310a00")))!!.map { it.value }, - ) - } + /** The 03:57 capture: 8 records, real ring-times (2.3-4.6 s apart), including held-PI samples. */ + private val capture = listOf( + 44_173_853L to "0023619125d576248772235a72", + 44_173_899L to "0022c37727296a271e6d23546c", + 44_173_922L to "0023266c238f6c232094282f84", + 44_173_967L to "002a6a932a6b922faff2310aff", + 44_174_013L to "00310bff310aff310aff310aff", + 44_174_058L to "003104ff2477c7261ac622cb6b", + 44_174_081L to "00228b9622439622b1d42497ee", + 44_174_122L to "002247db23cdc5246bd7258ada", + ) @Test - fun saturatedPerfusionSampleIsSkippedButKeepsItsSecond() { - // First sample carries PI 0xFF (the held ~0.766 R); the rest are real. - val out = OuraDecoders.decodeSpO2RatioPi(rec("003104ff2477c7261ac622cb6b"))!! - assertEquals(listOf(98, 97, 98), out.map { it.value }) - assertEquals(listOf(1, 2, 3), out.map { it.index }) - assertTrue(out.all { it.count == 4 }) - // A record that is ALL held values yields nothing. - assertNull(OuraDecoders.decodeSpO2RatioPi(rec("00310bff310aff310aff310aff"))) + fun decodesTheRecordVerbatim() { + val r = OuraDecoders.decodeSpO2RatioPi(rec("0031a28832849133547832d66a"))!! + assertEquals(listOf(0x31a2, 0x3284, 0x3354, 0x32d6), r.rX16384) + assertEquals(listOf(0x88, 0x91, 0x78, 0x6a), r.pi) + assertEquals(44_340_307L, r.ringTimestamp) + // Held-PI samples are kept verbatim at decode time; the estimate filters them. + assertEquals(listOf(0xff, 0xff, 0xff, 0xff), OuraDecoders.decodeSpO2RatioPi(rec("00310bff310aff310aff310aff"))!!.pi) } @Test fun malformedPayloadsDecodeToNull() { assertNull(OuraDecoders.decodeSpO2RatioPi(rec("00"))) assertNull(OuraDecoders.decodeSpO2RatioPi(rec("0031a28832"))) // not 1 + 3n - // The smallest well-formed record: one header byte + one sample. - assertEquals(listOf(93), OuraDecoders.decodeSpO2RatioPi(rec("0031a288"))!!.map { it.value }) } @Test - fun percentIsClampedToTheDocumentedRange() { - assertEquals(100, OuraSpO2Ratio.percent(0.0)) - assertEquals(85, OuraSpO2Ratio.percent(1.5)) - assertEquals(97, OuraSpO2Ratio.percent(0.6)) // 105.2 - 3.06 - 4.824 = 97.316 + fun everyCapturedRecordSurvivesToItsOwnStoredRowWithItsSource() { + val d = OuraDriver(ringGen = OuraRingGen.GEN4, authKey = IntArray(16) { it }) + val base = 1_790_468_000L + for (phase in listOf(0.0, 0.3, 0.6, 0.9)) { + val events = capture.flatMap { (rt, hex) -> d.ingest(rec(hex, rt)) } + assertTrue(events.all { it is OuraEvent.Spo2Ratio }) + // Anchor ring ticks (0.1 s) to whole seconds at several sub-second phases, like the live anchor. + val streams = OuraStreamMapping.streams(events) { rt -> (base + Math.floor(rt / 10.0 + phase)).toInt() } + val batch = StreamPersistence.toBatch(streams) + // Nothing lands in spo2Sample (no unit column there), and every record keeps a distinct key. + assertTrue(batch.spo2.isEmpty()) + assertEquals(capture.size, batch.events.map { it.ts }.toSet().size) + assertTrue(batch.events.all { it.kind == OuraStreamMapping.EVENT_SPO2_RPI }) + } + val one = StreamPersistence.toBatch( + OuraStreamMapping.streams(d.ingest(rec("0031a28832849133547832d66a"))) { 1_790_000_000 }, + ).events.single() + assertEquals("""{"pi":[136,145,120,106],"r_x16384":[12706,12932,13140,13014]}""", one.payloadJSON) } @Test - fun driverRoutesTheTagAndMappingPersistsDerivedPercentages() { - val d = OuraDriver(ringGen = OuraRingGen.GEN4, authKey = IntArray(16) { it }) - val events = d.ingest(rec("0023619125d576248772235a72")) - assertEquals(4, events.size) - assertTrue(events.all { it is OuraEvent.Spo2 }) - val streams = OuraStreamMapping.streams(events) { 1_790_000_000 } - assertEquals(listOf(1_789_999_997, 1_789_999_998, 1_789_999_999, 1_790_000_000), streams.spo2.map { it.ts }) - assertTrue(streams.spo2.all { it.red == 98 && it.ir == 0 && it.unit == OuraSpO2Channel.RATIO_PERCENT_UNIT }) + fun nightlySummaryUsesInSessionRecordsAndSkipsHeldSamples() { + val night = DetectedSleep(start = 1_000L, end = 2_000L, efficiency = 1.0, stages = emptyList(), restingHR = null, avgHRV = null) + fun row(ts: Long, hex: String) = EventRow( + "oura-x", ts, OuraStreamMapping.EVENT_SPO2_RPI, + StreamPersistence.toBatch(OuraStreamMapping.streams(listOf(OuraEvent.Spo2Ratio(OuraDecoders.decodeSpO2RatioPi(rec(hex))!!))) { ts.toInt() }) + .events.single().payloadJSON, + ) + val rows = listOf( + row(1_100L, "0023619125d576248772235a72"), // 98 98 98 98 + row(1_200L, "003104ff2477c7261ac622cb6b"), // [held] 98 97 98 + row(1_300L, "00310bff310aff310aff310aff"), // all held -> nothing + row(3_000L, "0031a28832849133547832d66a"), // outside the night -> ignored + ) + val s = OuraSpO2Nightly.summary(listOf(night), rows)!! + assertEquals(7, s.samples) + assertEquals(97, s.low) + assertEquals(98, s.high) + assertEquals(98.0, s.median, 0.0) + assertEquals((98 * 6 + 97) / 7.0, s.mean, 1e-9) + assertEquals(98, OuraSpO2Nightly.displayMean(s)) + assertNull(OuraSpO2Nightly.summary(listOf(night), rows.takeLast(1))) } @Test - fun channelNamesTheDerivedPercentage() { - assertEquals(OuraSpO2Channel.RATIO_PERCENTAGE, OuraSpO2Channel.forUnit(OuraSpO2Channel.RATIO_PERCENT_UNIT)) - assertEquals(OuraSpO2Channel.PERCENTAGE, OuraSpO2Channel.forUnit(OuraSpO2Channel.PERCENTAGE_UNIT)) + fun percentIsClampedToTheDocumentedRange() { + assertEquals(100, OuraSpO2Ratio.percent(0.0)) + assertEquals(85, OuraSpO2Ratio.percent(1.5)) + assertEquals(93, OuraSpO2Ratio.percent(0x31a2 / 16384.0)) } } diff --git a/docs/OURA_PROTOCOL.md b/docs/OURA_PROTOCOL.md index 9ca2d86318..7e7e96d7fa 100644 --- a/docs/OURA_PROTOCOL.md +++ b/docs/OURA_PROTOCOL.md @@ -695,7 +695,7 @@ like its sibling banked streams (`.hrv`/`.temp`/`.spo2`/`.sleepPhase`) — the f The two sets differ by <1 % on test data, so the mapping is robust to picking the wrong one; the Ring 5 mapping is unconfirmed in app 7.18. A naive generic `110 − 25·r` reads ~91 % against a calibrated ~93.4 % — i.e. materially worse. [open_oura-spo2] -- **Ring 4 capture (Android fork, 2026-09-27):** after the Test Centre feature-mode write set SpO2 to automatic on a server-gated ring (§7.5), the first night produced **5,236** `0x8b` records (vs 0 before) at a ~4.5 s record cadence. Every logged payload is `1 + 3n` bytes (one header byte `00`, then `n = 4` samples of u16 **big-endian** R ÷ 16384 + u8 PI), matching [oura-rs]; R sat at 0.53–0.89 and the gen4 quadratic above gives 90–99 %. **Android decodes it** (`OuraDecoders.decodeSpO2RatioPi` → `OuraSpO2Ratio.percent`, clamped 85–100, rounded) into per-second `spo2Sample` rows tagged `r_pi_pct`, laid backward from the record time like `0x6F`; they feed only the SpO2 estimate display path (never `spo2Pct`, never scored). A sample whose PI byte is `0xFF` is skipped (keeping its second): all 6 of 64 such samples in the capture carried the same R (0.766), the shape of a held / no-signal value — inferred, not documented. +- **Ring 4 capture (Android fork, 2026-09-27):** after the Test Centre feature-mode write set SpO2 to automatic on a server-gated ring (§7.5), the first night produced **5,236** `0x8b` records (vs 0 before) at a ~4.5 s record cadence. Every logged payload is `1 + 3n` bytes (one header byte `00`, then `n = 4` samples of u16 **big-endian** R ÷ 16384 + u8 PI), matching [oura-rs]; R sat at 0.53–0.89 and the gen4 quadratic above gives 90–99 %. **Android stores it verbatim** (`OuraDecoders.decodeSpO2RatioPi` → one `OURA_SPO2_RPI` event per record: raw R u16s + PI bytes, record-level `ts`) and converts at read time (`OuraSpO2Ratio.percent`, clamped 85–100) into a nightly low / median / mean / high over the records inside the detected sleep session (`OuraSpO2Nightly`), shown only through the SpO2 estimate path (never `spo2Pct`, never scored). Per-second rows were rejected: records arrive **2.2–4.6 s** apart yet carry 4 samples, so the within-record sample spacing is not 1 s and per-second keys collided (5–8 of 58 captured samples lost on the `(deviceId, ts)` key), and `spo2Sample` has no unit column to keep the derived value apart from the firmware `0x6F` percentage. Samples whose PI byte is `0xFF` are skipped by the estimate (kept in storage): all 6 of 64 such samples in the capture carried the same R (0.766, ~93 %), the shape of a held / no-signal value — inferred, not documented. - **Before that capture, NOOP had never received `0x8b`**: 0 occurrences across every capture checked (vs 5,585 × `0x6F` and 21,706 × `0x77` in the same files), and it is absent from `OuraEventTag`. Whether that is generation-specific (the published coefficients name Gen4/Oreo and Cooper), server-flag gated like SpO2