diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 45dad6876b..0ef49e05d6 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -3499,15 +3499,27 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { /// Newest plausible-unix marker in a GET_DATA_RANGE COMMAND_RESPONSE = the strap's newest stored /// record. Mirrors re/diagnose_biometrics.py: scan u32 LE words in the response body (data starts at /// frame[7], after [type,seq,cmd]), keep those in the unix range, return the max. nil if none. - static func dataRangeNewestUnix(from frame: [UInt8]) -> Int? { - guard frame.count > 7 else { return nil } - let body = Array(frame[7...]); var newest: Int? = nil; var i = 0 - while i + 4 <= body.count { - let w = Int(body[i]) | Int(body[i+1]) << 8 | Int(body[i+2]) << 16 | Int(body[i+3]) << 24 - if w >= 1_700_000_000 && w <= 1_900_000_000 { newest = max(newest ?? 0, w) } - i += 4 + static func dataRangeNewestUnix(from frame: [UInt8], + wallNowUnix: Int = Int(Date().timeIntervalSince1970)) -> Int? { + guard frame.count >= 4 else { return nil } + // Scan EVERY byte offset (was: 4-byte words from offset 7). The strap's newest-record u32 does not + // always land on that grid — on WHOOP 4 it sits at byte offset 8, so the aligned scan straddled it + // and returned nil, leaving a stale/garbage word latched in strapNewestTs. Harmless until #228 wired + // that value into BackfillPolicy: a FUTURE-reading value now skips the periodic offload, so one bad + // read stalls auto-sync (#451/#928/#1012). + let futureCutoff = wallNowUnix + BackfillContinuation.defaultFutureSkewSeconds + var newestNotFuture: Int? = nil; var newestAny: Int? = nil; var i = 0 + while i + 4 <= frame.count { + let w = Int(frame[i]) | Int(frame[i+1]) << 8 | Int(frame[i+2]) << 16 | Int(frame[i+3]) << 24 + if w >= 1_700_000_000 && w <= 1_900_000_000 { + newestAny = max(newestAny ?? 0, w) + if w <= futureCutoff { newestNotFuture = max(newestNotFuture ?? 0, w) } + } + i += 1 } - return newest + // Prefer the newest word at/behind the wall clock (a device can't record the future); return the + // future max only if EVERY plausible word is future — a genuinely future-set RTC (#928). + return newestNotFuture ?? newestAny } /// The OLDEST plausible record timestamp in a GET_DATA_RANGE frame — the start of the strap's stored 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 9a67da0703..a788fce51e 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -797,19 +797,37 @@ class WhoopBleClient( * record. Mirrors Swift `BLEManager.dataRangeNewestUnix`: scan u32 LE words in the response * body (starts at frame[7], after [type,seq,cmd]), keep those in the unix range, return max. */ - fun dataRangeNewestUnix(frame: ByteArray): Long? { - if (frame.size <= 7) return null - var newest: Long? = null - var i = 7 + fun dataRangeNewestUnix( + frame: ByteArray, + wallNowUnix: Long = System.currentTimeMillis() / 1000L, + ): Long? { + if (frame.size < 4) return null + // Scan EVERY byte offset (was: 4-byte words aligned FROM offset 7). The strap's newest-record + // u32 does not always land on that grid — on WHOOP 4 it sits at byte offset 8, so the aligned + // scan straddled it and returned null, leaving a stale/garbage word from an earlier misaligned + // read latched in [strapNewestTs]. Harmless until #228 wired that value into BackfillPolicy: + // a FUTURE-reading strapNewestTs now SKIPS the automatic periodic/strap offload + // ([isFutureDatedNewest]), so one bad read stalls auto-sync (#451/#928/#1012). + var newestNotFuture: Long? = null + var newestAny: Long? = null + val futureCutoff = wallNowUnix + AUTO_CONTINUE_FUTURE_SKEW_SECONDS + var i = 0 while (i + 4 <= frame.size) { val w = (frame[i].toLong() and 0xFFL) or ((frame[i + 1].toLong() and 0xFFL) shl 8) or ((frame[i + 2].toLong() and 0xFFL) shl 16) or ((frame[i + 3].toLong() and 0xFFL) shl 24) - if (w in 1_700_000_000L..1_900_000_000L) newest = maxOf(newest ?: 0L, w) - i += 4 + if (w in 1_700_000_000L..1_900_000_000L) { + newestAny = maxOf(newestAny ?: 0L, w) + if (w <= futureCutoff) newestNotFuture = maxOf(newestNotFuture ?: 0L, w) + } + i += 1 } - return newest + // A device cannot record the future: prefer the newest word AT/BEHIND the wall clock (the true + // frontier), so a lone future-dated straddle can't hijack it. Only when EVERY plausible word is + // future — a genuinely future-set RTC (#928) — return the future max, so [isFutureDatedNewest] + // still fires and the auto-continue / periodic guards correctly refuse the range. + return newestNotFuture ?: newestAny } /** OLDEST plausible record timestamp in a GET_DATA_RANGE frame — the start of the strap's stored diff --git a/android/app/src/test/java/com/noop/ble/DataRangeScanTest.kt b/android/app/src/test/java/com/noop/ble/DataRangeScanTest.kt new file mode 100644 index 0000000000..efe80d3212 --- /dev/null +++ b/android/app/src/test/java/com/noop/ble/DataRangeScanTest.kt @@ -0,0 +1,70 @@ +package com.noop.ble + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * [WhoopBleClient.dataRangeNewestUnix] offset/future-date correctness (#451/#928/#1012). + * + * Regression fixtures are David's REAL WHOOP 4.0 GET_DATA_RANGE frames captured 2026-07-11: the newest + * banked record is 2026-07-11 16:00 (unix 1_783_785_625) and it sits at BYTE OFFSET 8 — off the old + * 4-byte-aligned-from-7 grid, so the old scan returned null and a stale FUTURE word stayed latched in + * strapNewestTs, which #228 then used to skip the periodic offload → auto-sync stalled ~25 min. + */ +class DataRangeScanTest { + + private fun hex(s: String) = ByteArray(s.length / 2) { + ((Character.digit(s[it * 2], 16) shl 4) + Character.digit(s[it * 2 + 1], 16)).toByte() + } + + /** Little-endian u32 [value] written at [offset] into a [size]-byte frame (rest zero). */ + private fun frameWithU32(size: Int, offset: Int, value: Long): ByteArray { + val b = ByteArray(size) + for (k in 0..3) b[offset + k] = ((value shr (8 * k)) and 0xFF).toByte() + return b + } + + private val WALL_NOW = 1_783_786_000L // 2026-07-11 ~16:06, just after the captured newest + private val REAL_NEWEST = 1_783_785_625L // 2026-07-11 16:00:25 (frame a) + + @Test + fun `reads the real WHOOP4 newest at byte offset 8 (old scan-from-7 missed it, returned null)`() { + // Three real captured frames → their true newest (offset-8 u32), all 2026-07-11 ~16:00, a few + // seconds apart. Each is well behind WALL_NOW, so none is treated as future. + val expected = mapOf( + "aa100057305d22009968526a083900001d2e2263" to 1_783_785_625L, // 16:00:25 + "aa10005730612200a268526ab0290000e87d155d" to 1_783_785_634L, // 16:00:34 + "aa100057307c2200e768526a78760000c997138d" to 1_783_785_703L, // 16:01:43 + ) + for ((h, ts) in expected) { + assertEquals( + "newest for $h should be its offset-8 value, not null", + ts, + WhoopBleClient.dataRangeNewestUnix(hex(h), WALL_NOW), + ) + } + } + + @Test + fun `a lone future-dated straddle never becomes the frontier`() { + // Real "today" word plus a fabricated far-future word: the true frontier is today, not the future. + val future = WALL_NOW + 400L * 86_400L + val frame = frameWithU32(16, 4, REAL_NEWEST) + for (k in 0..3) frame[10 + k] = ((future shr (8 * k)) and 0xFF).toByte() + assertEquals(REAL_NEWEST, WhoopBleClient.dataRangeNewestUnix(frame, WALL_NOW)) + } + + @Test + fun `all-future frame (genuinely wrong RTC) still returns future so the guard fires`() { + // The strap's ONLY plausible-unix word is future → return it so isFutureDatedNewest detects the + // real bad-clock case and the periodic/auto-continue guards correctly refuse the range (#928). + val future = WALL_NOW + 400L * 86_400L + assertEquals(future, WhoopBleClient.dataRangeNewestUnix(frameWithU32(12, 4, future), WALL_NOW)) + } + + @Test + fun `a value just inside the 48h skew is treated as present, not future`() { + val within = WALL_NOW + 40L * 3600L // 40h ahead, under the 48h AUTO_CONTINUE_FUTURE_SKEW + assertEquals(within, WhoopBleClient.dataRangeNewestUnix(frameWithU32(12, 4, within), WALL_NOW)) + } +}