From a43d3ed2494a407d42b335d8bd2928aca6859842 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Tue, 21 Jul 2026 18:21:39 +1200 Subject: [PATCH] Decode GET_DATA_RANGE pagesBehind for sync diagnostics (#689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DataRange.pagesBehind (Swift + Kotlin byte-parity twins) — the ring-buffer page backlog the WHOOP app computes from three u32s in the GET_DATA_RANGE command-response inner payload: write page W=V(2), read pointer U=V(3), ring capacity T=V(5) at frame offsets cmdOff+10/14/22, backlog W --- .../Sources/WhoopProtocol/DataRange.swift | 28 ++++++++++++++++ .../WhoopProtocolTests/DataRangeTests.swift | 32 +++++++++++++++++++ Strand/BLE/BLEManager.swift | 5 +++ .../main/java/com/noop/ble/WhoopBleClient.kt | 5 +++ .../main/java/com/noop/protocol/DataRange.kt | 28 ++++++++++++++++ .../java/com/noop/ble/DataRangeScanTest.kt | 29 +++++++++++++++++ docs/PROTOCOL.md | 13 +++++++- 7 files changed, 139 insertions(+), 1 deletion(-) diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/DataRange.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/DataRange.swift index 2d3f30535..6e7b30330 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/DataRange.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/DataRange.swift @@ -56,4 +56,32 @@ public enum DataRange { } return oldest } + + /// #689: the ring-buffer page backlog ("pages behind") the strap reports in a GET_DATA_RANGE response — + /// DIAGNOSTIC ONLY. RE'd from the WHOOP app (facts, not copied code; see ATTRIBUTION.md) and NOT yet + /// confirmed against real 4.0 / 5-MG captures, so it NEVER gates sync or backfill — it only logs. + /// + /// The app reads three u32s from the command-response INNER payload (whose byte 0 is a subtype), at + /// `V(i) = word @ (i*4 + 1)`: write page `W = V(2)`, read pointer `U = V(3)`, ring capacity `T = V(5)`. + /// The inner payload starts at `cmdOff + 1`, so those words sit at frame offsets `cmdOff + 10/14/22` + /// here. Read u32 LITTLE-endian to match the frame's other words (the app's ByteBuffer default is + /// big-endian, but this frame carries its unix words LE — a fixture will settle it; a flip is one line). + /// Backlog with wraparound: `W < U ? W + (T - U) : W - U`. + /// + /// Returns nil for a too-short frame or implausible values — a capacity that is 0 or above a sane + /// ceiling (a misaligned read hitting a timestamp / `0xFFFFFFFF`), a pointer at/beyond capacity, or a + /// backlog past capacity — so a garbage frame can never log a nonsense number. + public static func pagesBehind(from frame: [UInt8], cmdOff: Int) -> Int? { + guard cmdOff >= 0 else { return nil } + let wOff = cmdOff + 10, uOff = cmdOff + 14, tOff = cmdOff + 22 + guard tOff + 4 <= frame.count else { return nil } + func u32(_ o: Int) -> Int { + Int(frame[o]) | Int(frame[o + 1]) << 8 | Int(frame[o + 2]) << 16 | Int(frame[o + 3]) << 24 + } + let w = u32(wOff), u = u32(uOff), t = u32(tOff) + guard t > 0, t <= 0x00FF_FFFF, w < t, u < t else { return nil } + let behind = w < u ? w + (t - u) : w - u + guard behind >= 0, behind <= t else { return nil } + return behind + } } diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DataRangeTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DataRangeTests.swift index f04e02df9..b44037adf 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DataRangeTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DataRangeTests.swift @@ -77,4 +77,36 @@ final class DataRangeTests: XCTestCase { for k in 0..<4 { frame[11 + k] = UInt8((1_750_000_000 >> (8 * k)) & 0xFF) } // grid offset 11 XCTAssertEqual(DataRange.oldestUnix(from: frame), 1_750_000_000) } + + // MARK: - #689 pagesBehind (ring backlog). Byte-parity twin of the Kotlin DataRangeScanTest cases. + + /// Frame (zero-filled) with W/U/T u32 LE at the pagesBehind offsets (cmdOff+10/14/22). + private func pagesFrame(cmdOff: Int, w: Int, u: Int, t: Int, size: Int = 40) -> [UInt8] { + var b = [UInt8](repeating: 0, count: size) + func put(_ off: Int, _ v: Int) { for k in 0..<4 { b[off + k] = UInt8((v >> (8 * k)) & 0xFF) } } + put(cmdOff + 10, w); put(cmdOff + 14, u); put(cmdOff + 22, t) + return b + } + + func testPagesBehind_normalNoWrap() { // W > U ⇒ W − U + XCTAssertEqual(DataRange.pagesBehind(from: pagesFrame(cmdOff: 6, w: 500, u: 200, t: 1024), cmdOff: 6), 300) + } + + func testPagesBehind_wraparound() { // W < U ⇒ W + (T − U) + XCTAssertEqual(DataRange.pagesBehind(from: pagesFrame(cmdOff: 6, w: 100, u: 800, t: 1000), cmdOff: 6), 300) + } + + func testPagesBehind_whoop5CmdOff10() { // offsets shift with cmdOff; same math + XCTAssertEqual(DataRange.pagesBehind(from: pagesFrame(cmdOff: 10, w: 500, u: 200, t: 1024), cmdOff: 10), 300) + } + + func testPagesBehind_tooShortIsNil() { + XCTAssertNil(DataRange.pagesBehind(from: [UInt8](repeating: 0, count: 20), cmdOff: 6)) + } + + func testPagesBehind_implausibleIsNil() { + XCTAssertNil(DataRange.pagesBehind(from: pagesFrame(cmdOff: 6, w: 1, u: 1, t: 0), cmdOff: 6)) // capacity 0 + XCTAssertNil(DataRange.pagesBehind(from: pagesFrame(cmdOff: 6, w: 1, u: 1, t: 1_783_785_625), cmdOff: 6)) // T is a timestamp → over ceiling + XCTAssertNil(DataRange.pagesBehind(from: pagesFrame(cmdOff: 6, w: 5, u: 2000, t: 1000), cmdOff: 6)) // U ≥ T + } } diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 4e6f1ee64..35048493a 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -4032,6 +4032,11 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // offsets are inspectable straight from a normal strap-log export. Short frame. let hex = frame.map { String(format: "%02x", $0) }.joined() log("Get Data Range raw frame (#451 — for offset analysis): \(hex)") + // #689: ring-buffer page backlog, DIAGNOSTIC ONLY (RE'd, unconfirmed — never gates + // sync/backfill). Logged only when it decodes plausibly; a short/garbage frame → nil. + if let pages = DataRange.pagesBehind(from: frame, cmdOff: 6) { + log("Strap backlog pages behind: \(pages) (#689 — GET_DATA_RANGE ring backlog, diagnostic only)") + } if let newest = BLEManager.dataRangeNewestUnix(from: frame) { strapNewestTs = newest // feeds the liveness watchdog // #928: flag an implausibly FUTURE "newest" (strap clock set ahead) right where it diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index 206d3d49b..f4c122eb3 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -4176,6 +4176,11 @@ class WhoopBleClient( // dataRangeNewestUnix straight from a normal strap-log export. Mirrors the Swift line. val hex = frame.joinToString("") { "%02x".format(it) } log("Get Data Range raw frame (#451 — for offset analysis): $hex") + // #689: ring-buffer page backlog, DIAGNOSTIC ONLY (RE'd, unconfirmed — never gates + // sync/backfill). Logged only when it decodes plausibly; a short/garbage frame → null. + com.noop.protocol.DataRange.pagesBehind(frame, cmdOff)?.let { + log("Strap backlog pages behind: $it (#689 — GET_DATA_RANGE ring backlog, diagnostic only)") + } dataRangeNewestUnix(frame)?.let { strapNewestTs = it // #34: persist the strap's newest banked record so the debug export can flag a reset clock. diff --git a/android/app/src/main/java/com/noop/protocol/DataRange.kt b/android/app/src/main/java/com/noop/protocol/DataRange.kt index 4e8e8ceef..5396eff32 100644 --- a/android/app/src/main/java/com/noop/protocol/DataRange.kt +++ b/android/app/src/main/java/com/noop/protocol/DataRange.kt @@ -64,4 +64,32 @@ object DataRange { } return oldest } + + /** + * #689: the ring-buffer page backlog ("pages behind") the strap reports in a GET_DATA_RANGE response — + * DIAGNOSTIC ONLY. RE'd from the WHOOP app (facts, not copied code; see ATTRIBUTION.md), NOT yet + * confirmed against real 4.0 / 5-MG captures, so it NEVER gates sync or backfill — only logged. + * Mirrors Swift `DataRange.pagesBehind`. + * + * Reads three u32s from the command-response INNER payload (byte 0 a subtype), `V(i) = @ (i*4 + 1)`: + * write page W=V(2), read pointer U=V(3), ring capacity T=V(5) — at frame offsets cmdOff + 10/14/22 + * (inner payload starts at cmdOff + 1). u32 LITTLE-endian to match the frame's other words. Backlog + * with wraparound: W frame.size) return null + fun u32(o: Int): Long = + (frame[o].toLong() and 0xFFL) or + ((frame[o + 1].toLong() and 0xFFL) shl 8) or + ((frame[o + 2].toLong() and 0xFFL) shl 16) or + ((frame[o + 3].toLong() and 0xFFL) shl 24) + val w = u32(wOff); val u = u32(uOff); val t = u32(tOff) + if (t <= 0L || t > 0x00FFFFFFL || w >= t || u >= t) return null + val behind = if (w < u) w + (t - u) else w - u + if (behind < 0L || behind > t) return null + return behind + } } diff --git a/android/app/src/test/java/com/noop/ble/DataRangeScanTest.kt b/android/app/src/test/java/com/noop/ble/DataRangeScanTest.kt index 2a76ce89b..587fc7903 100644 --- a/android/app/src/test/java/com/noop/ble/DataRangeScanTest.kt +++ b/android/app/src/test/java/com/noop/ble/DataRangeScanTest.kt @@ -1,6 +1,8 @@ package com.noop.ble +import com.noop.protocol.DataRange import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test /** @@ -92,4 +94,31 @@ class DataRangeScanTest { for (k in 0..3) frame[11 + k] = ((1_750_000_000L shr (8 * k)) and 0xFF).toByte() // grid offset 11 assertEquals(1_750_000_000L, WhoopBleClient.dataRangeOldestUnix(frame)) } + + // #689 pagesBehind (ring backlog): byte-parity twin of the Swift DataRangeTests cases. + + private fun pagesFrame(cmdOff: Int, w: Long, u: Long, t: Long, size: Int = 40): ByteArray { + val b = ByteArray(size) + fun put(off: Int, v: Long) { for (k in 0..3) b[off + k] = ((v shr (8 * k)) and 0xFF).toByte() } + put(cmdOff + 10, w); put(cmdOff + 14, u); put(cmdOff + 22, t) + return b + } + + @Test fun `pagesBehind normal no wrap`() = + assertEquals(300L, DataRange.pagesBehind(pagesFrame(6, 500, 200, 1024), 6)) + + @Test fun `pagesBehind wraparound`() = + assertEquals(300L, DataRange.pagesBehind(pagesFrame(6, 100, 800, 1000), 6)) + + @Test fun `pagesBehind whoop5 cmdOff 10`() = + assertEquals(300L, DataRange.pagesBehind(pagesFrame(10, 500, 200, 1024), 10)) + + @Test fun `pagesBehind too short is null`() = + assertNull(DataRange.pagesBehind(ByteArray(20), 6)) + + @Test fun `pagesBehind implausible is null`() { + assertNull(DataRange.pagesBehind(pagesFrame(6, 1, 1, 0), 6)) // capacity 0 + assertNull(DataRange.pagesBehind(pagesFrame(6, 1, 1, 1_783_785_625L), 6)) // T is a timestamp + assertNull(DataRange.pagesBehind(pagesFrame(6, 5, 2000, 1000), 6)) // U >= T + } } diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 5ed524f32..618443c25 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -342,7 +342,7 @@ public func frame(seq: UInt8, payload: [UInt8] = [0x00]) -> [UInt8] { | 22 | `SEND_HISTORICAL_DATA` | `[0x00]` | begin offload of the type-47 store | | 23 | `HISTORICAL_DATA_RESULT` | `[0x01] + end_data(8)` | ack a `HISTORY_END` chunk / advance trim | | 26 | `GET_BATTERY_LEVEL` | `[0x00]` | battery percent; also the **bond** write | -| 34 | `GET_DATA_RANGE` | `[0x00]` | strap's stored oldest/newest record range | +| 34 | `GET_DATA_RANGE` | `[0x00]` | strap's stored oldest/newest record range; #689 also logs a diagnostic ring-buffer page backlog — see below | | 35 | `GET_HELLO_HARVARD` | `[0x00]` | identity/version hello | | 39 / 40 | `SET_LED_DRIVE` / `GET_LED_DRIVE` | — | optical LED drive (research) | | 41 / 42 | `SET_TIA_GAIN` / `GET_TIA_GAIN` | — | optical front-end gain (research) | @@ -445,6 +445,17 @@ formatted by the pure `BodyLocationProbe` twin (Swift↔Kotlin byte-parity locke layout + enum facts are reverse-engineered from the WHOOP app and reimplemented in NOOP's own code (facts, not copied expression — see [`ATTRIBUTION.md`](../ATTRIBUTION.md)). +**GET_DATA_RANGE ring backlog (#689, diagnostic only).** Beyond the oldest/newest timestamps NOOP already +scans from a `GET_DATA_RANGE` reply, the app computes a ring-buffer page backlog from three u32s in the +command-response inner payload (whose byte 0 is a subtype): write page `W = V(2)`, read pointer `U = V(3)`, +ring capacity `T = V(5)`, where `V(i)` is the u32 at inner offset `i·4 + 1` (frame offsets `cmdOff + 10/14/22` +here). Backlog with wraparound: `W < U ? W + (T − U) : W − U`. `DataRange.pagesBehind` (Swift + Kotlin twins, +byte-parity, unit-tested for normal / wraparound / too-short / implausible) logs `Strap backlog pages behind: +N` when it decodes plausibly — read u32 LE, guarded on frame length + a capacity sanity ceiling. **Never** +gates sync or backfill: the layout is RE'd from the WHOOP app (facts, reimplemented in NOOP's own code, see +[`ATTRIBUTION.md`](../ATTRIBUTION.md)) but **not yet confirmed against real 4.0 / 5-MG captures**, so it stays +a log-only diagnostic until a fixture pins the offsets + endianness. + **Payload forms** (decoded from the official app's command builders — recorded so the wire format is *known*: for the destructive commands, known-and-avoidable; for the one guarded exception, `REBOOT_STRAP`, known-and-used by `rebootStrap()`). The opcodes are shared across WHOOP 4 (harvard)