Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions Packages/WhoopProtocol/Sources/WhoopProtocol/DataRange.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
5 changes: 5 additions & 0 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions android/app/src/main/java/com/noop/protocol/DataRange.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<U ? W+(T-U) : W-U. Returns null for a too-short frame or implausible values
* (capacity 0 or over a sane ceiling, a pointer at/beyond capacity, or a backlog past capacity).
*/
fun pagesBehind(frame: ByteArray, cmdOff: Int): Long? {
if (cmdOff < 0) return null
val wOff = cmdOff + 10; val uOff = cmdOff + 14; val tOff = cmdOff + 22
if (tOff + 4 > 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
}
}
29 changes: 29 additions & 0 deletions android/app/src/test/java/com/noop/ble/DataRangeScanTest.kt
Original file line number Diff line number Diff line change
@@ -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

/**
Expand Down Expand Up @@ -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
}
}
13 changes: 12 additions & 1 deletion docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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)
Expand Down
Loading