Skip to content
Open
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
11 changes: 11 additions & 0 deletions Packages/WhoopProtocol/Sources/WhoopProtocol/DataRange.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,17 @@ public enum DataRange {
return behind
}

/// The last-written page's clock (unix seconds) from a 5/MG GET_DATA_RANGE SUCCESS reply: the u32 at body
/// offset 57, i.e. `cmdOff + 60`. Field-exact, no scan. nil for a short frame or the 0 / 0xFFFFFFFF
/// "no clock" sentinels. Confirmed on 589 real 5/MG replies (wearable5.0 FINDINGS §3). Kotlin twin:
/// `DataRange.pageClockNewestUnix`.
public static func pageClockNewestUnix(from frame: [UInt8], cmdOff: Int) -> Int? {
let o = cmdOff + 60
guard cmdOff >= 0, o + 4 <= frame.count else { return nil }
let v = Int(frame[o]) | Int(frame[o + 1]) << 8 | Int(frame[o + 2]) << 16 | Int(frame[o + 3]) << 24
return (v == 0 || v == 0xFFFF_FFFF) ? nil : v
}

/// The BLE seam's decision on a GET_DATA_RANGE COMMAND_RESPONSE: is this a reply to `opcode`, and
/// may it move state? A reply is accepted only on the verifier's FULL verdict.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,21 @@ final class DataRangeTests: XCTestCase {
XCTAssertTrue(parseFrame(other, family: .whoop4).ok)
XCTAssertFalse(DataRange.acceptsReply(other, cmdOff: 6, opcode: getDataRangeOpcode, verdictOK: true))
}

/// wearable5.0 golden vectors (synthetic 5/MG replies built to the field layout confirmed on 589 real
/// replies): the newest-page clock at `cmdOff + 60`, and the 0xFFFFFFFF sentinel read as no clock.
func testWhoop5PageClockNewestIsFieldExact() {
func hex(_ h: String) -> [UInt8] {
stride(from: 0, to: h.count, by: 2).map { i in
UInt8(h[h.index(h.startIndex, offsetBy: i)..<h.index(h.startIndex, offsetBy: i + 2)], radix: 16)!
}
}
let normal = hex("aa014c00010032d12405220401010000000000000000780000006400000000000000000002000000000000000000000000000000000000000000000000000000000000000000fb3bb16a00000000000010266950")
let sentinel = hex("aa014c00010032d1240522040101000000000000000005000000feff010000000000000002000000000000000000000000000000000000000000000000000000000000000000ffffffff0000000000009904b681")
XCTAssertEqual(DataRange.pageClockNewestUnix(from: normal, cmdOff: 10), 1_790_000_123)
XCTAssertEqual(DataRange.pagesBehind(from: normal, cmdOff: 10), 20)
XCTAssertNil(DataRange.pageClockNewestUnix(from: sentinel, cmdOff: 10))
XCTAssertEqual(DataRange.pagesBehind(from: sentinel, cmdOff: 10), 7)
XCTAssertNil(DataRange.pageClockNewestUnix(from: Array(normal.prefix(60)), cmdOff: 10))
}
}
15 changes: 10 additions & 5 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7212,7 +7212,12 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
+ "the raw frame above is the input). Diagnostic only, sync is unaffected.")
}
}
if let newest = BLEManager.dataRangeNewestUnix(from: frame) {
// 5/MG (cmdOff 10): the newest-page clock is a fixed field (body @57), read exactly instead of
// scanned; WHOOP 4.0 keeps the scan (wearable5.0 FINDINGS §3, 589 real replies).
let newestRead = cmdOff == 10
? DataRange.pageClockNewestUnix(from: frame, cmdOff: cmdOff)
: BLEManager.dataRangeNewestUnix(from: frame)
if let newest = newestRead {
// #695: the sync-affecting side-effects (strapNewestTs, backfill window, LiveState range) only
// apply when feedsSync — WHOOP4 today. The 5/MG path passes feedsSync=false: it LOGS the newest/
// oldest/backlog (so a strap log validates the decode) but leaves sync UNCHANGED until confirmed
Expand Down Expand Up @@ -7618,10 +7623,10 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
if frame.count > 10, frame[8] == 0x24,
DataRange.acceptsReply(frame, cmdOff: 10, opcode: WhoopCommand.getDataRange.rawValue,
verdictOK: parseFrame(frame, family: .whoop5).ok) {
// feedsSync: false — #695 diagnostic-only on 5/MG: log the dump/backlog/newest/oldest so
// a strap log validates the decode, but DON'T feed strapNewestTs/backfill/state yet.
// Flip to true once a real 5.0/MG strap confirms the newest/oldest are correct.
handleDataRangeResponse(frame, cmdOff: 10, feedsSync: false)
// feedsSync: true — the 5/MG newest-page clock is now read field-exact and was confirmed
// on 589 real replies (wearable5.0 FINDINGS §3), so it feeds strapNewestTs/backfill/state
// exactly as Android already does. Refs #1992.
handleDataRangeResponse(frame, cmdOff: 10, feedsSync: true)
}
// NOTE: we deliberately do NOT ingest live 5/MG REALTIME_DATA into the Collector
// here. For a 5/MG the standard 0x2A37 Heart-Rate profile is already the RELIABLE,
Expand Down
4 changes: 2 additions & 2 deletions Tools/parity_twin_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@
},
"authority": {
"files": {"count": 509, "sha256": "0a0b02d39eb26ad0635933ad6fca51fb4c33db98178410bfe6676ab9d0fdcaee"},
"functions": {"count": 4537, "sha256": "a10084c830d9b91c0f02bb308fa7ac0f1466c7537de3303669e0f4fbeffdf6f2"},
"functions": {"count": 4539, "sha256": "309f29b70818a016aa7b420dc3f24449ca6041516ae021afed7c2f72c7c11d75"},
"properties": {"count": 488, "sha256": "77d60fe0f6218252233b9e60da8bf5135968e49be3c6d2027d0973ea20ddf792"},
"constants": {"count": 2001, "sha256": "a9c1f36d2acc614dd747fd6cc0b3ea48079b81517afda5591d219f9591e353c3"},
"file_pairs": {"count": 81, "sha256": "3c94824ccff843da495a212a973d89ce61e536b6795dbee2d0e496a7e7a498f0"},
"function_pairs": {"count": 226, "sha256": "7c7f2298ad7921101241f39d86ecab774d21eadeb89d52d4d5db0397878905f2"},
"function_pairs": {"count": 227, "sha256": "a6c0b5bf1ede688ad67e62afbe25ccd2e8008843b03ee8678c3e7469c27ad45f"},
"property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"},
"constant_pairs": {"count": 702, "sha256": "e3de4c1b84b8500c5700292648bf23dc680e5e14cde055c36c5d1ef4ecc39dc2"},
"unpaired_files": {"count": 369, "sha256": "3462a2a8135bfd793dc6493f10fb72b4fae9028ad9ec47c8dec84b9f5a4dc01a"},
Expand Down
9 changes: 8 additions & 1 deletion android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8359,7 +8359,14 @@ class WhoopBleClient(
"values). The raw frame above is the evidence.",
)
}
(if (acceptsWindow) dataRangeNewestUnix(frame) else null)?.let {
// 5/MG: the newest-page clock is a fixed field (body @57), so read it exactly instead
// of scanning; the scan stays for WHOOP 4.0 (wearable5.0 FINDINGS §3, 589 real replies).
val newestRead = if (connectedFamily == DeviceFamily.WHOOP5) {
com.noop.protocol.DataRange.pageClockNewestUnix(frame, cmdOff)
} else {
dataRangeNewestUnix(frame)
}
(if (acceptsWindow) newestRead else null)?.let {
strapNewestTs = it
// Capture the wall clock of THIS reading so the backfiller correlation pairs
// the strap's device time with the wall time of the same instant (see field doc).
Expand Down
12 changes: 12 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 @@ -65,6 +65,18 @@ object DataRange {
return oldest
}

/** The last-written page's clock (unix seconds) from a 5/MG GET_DATA_RANGE SUCCESS reply: the u32 at
* body offset 57, i.e. `cmdOff + 60`. Field-exact, no scan. null for a short frame or the 0 / 0xFFFFFFFF
* "no clock" sentinels. Confirmed on 589 real 5/MG replies (wearable5.0 FINDINGS §3). Swift twin:
* `DataRange.pageClockNewestUnix`. */
fun pageClockNewestUnix(frame: ByteArray, cmdOff: Int): Long? {
val o = cmdOff + 60
if (cmdOff < 0 || o + 4 > frame.size) return null
val v = (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)
return if (v == 0L || v == 0xFFFFFFFFL) null else v
}

/**
* #689/#815: the ring-buffer page backlog ("pages behind") the strap reports in a GET_DATA_RANGE
* response — DIAGNOSTIC ONLY (feeds a future sync-progress UI, never gates sync or backfill itself).
Expand Down
12 changes: 12 additions & 0 deletions android/app/src/test/java/com/noop/ble/DataRangeScanTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,16 @@ class DataRangeScanTest {
/** A negative cmdOff is rejected rather than indexing backwards. */
@Test fun `isPendingResponse false for a negative cmdOff`() =
assertFalse(DataRange.isPendingResponse(ByteArray(40), -1))

/** wearable5.0 golden vectors: the 5/MG newest-page clock at `cmdOff + 60`, and the 0xFFFFFFFF sentinel
* read as no clock. Twin of the Swift `testWhoop5PageClockNewestIsFieldExact`. */
@Test fun `whoop5 page clock newest is field exact`() {
val normal = hex("aa014c00010032d12405220401010000000000000000780000006400000000000000000002000000000000000000000000000000000000000000000000000000000000000000fb3bb16a00000000000010266950")
val sentinel = hex("aa014c00010032d1240522040101000000000000000005000000feff010000000000000002000000000000000000000000000000000000000000000000000000000000000000ffffffff0000000000009904b681")
assertEquals(1_790_000_123L, DataRange.pageClockNewestUnix(normal, 10))
assertEquals(20L, DataRange.pagesBehind(normal, 10))
assertNull(DataRange.pageClockNewestUnix(sentinel, 10))
assertEquals(7L, DataRange.pagesBehind(sentinel, 10))
assertNull(DataRange.pageClockNewestUnix(normal.copyOf(60), 10))
}
}