From 79695b6c59ee05ff6e8894f1d01d025d040ff8ec Mon Sep 17 00:00:00 2001 From: LeonardSEO <130681849+LeonardSEO@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:30:54 +0200 Subject: [PATCH] Read the 5/MG GET_DATA_RANGE newest-page clock field-exact and feed it to sync on Apple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clock of the last-written page sits at body offset 57 (cmdOff + 60), confirmed on 589 real replies in LeonardSEO/wearable5.0 FINDINGS §3, with 0 and 0xFFFFFFFF as sentinels. Both platforms read it directly for 5/MG; Apple now feeds it to sync as Android already does. Golden vectors from wearable5.0. Not tested on a strap. Refs #1992 --- .../Sources/WhoopProtocol/DataRange.swift | 11 +++++++++++ .../WhoopProtocolTests/DataRangeTests.swift | 17 +++++++++++++++++ Strand/BLE/BLEManager.swift | 15 ++++++++++----- Tools/parity_twin_map.json | 4 ++-- .../main/java/com/noop/ble/WhoopBleClient.kt | 9 ++++++++- .../main/java/com/noop/protocol/DataRange.kt | 12 ++++++++++++ .../test/java/com/noop/ble/DataRangeScanTest.kt | 12 ++++++++++++ 7 files changed, 72 insertions(+), 8 deletions(-) diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/DataRange.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/DataRange.swift index e9dce4abfa..a106339a37 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/DataRange.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/DataRange.swift @@ -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. /// diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DataRangeTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DataRangeTests.swift index 0d97519c24..f25e3958c8 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DataRangeTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DataRangeTests.swift @@ -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).. 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, diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 767e162915..5a4e4db497 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -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"}, 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 8fd7e63730..4cd5d440b9 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -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). 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 d0cf8402f0..4e3d1668f4 100644 --- a/android/app/src/main/java/com/noop/protocol/DataRange.kt +++ b/android/app/src/main/java/com/noop/protocol/DataRange.kt @@ -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). 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 3f26a4224e..515d6601d4 100644 --- a/android/app/src/test/java/com/noop/ble/DataRangeScanTest.kt +++ b/android/app/src/test/java/com/noop/ble/DataRangeScanTest.kt @@ -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)) + } }