From c1784c5fccf85bf8d2a282ab21e9ccb113d8f23c 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] Stop the 5/MG history auto-continue when GET_DATA_RANGE reports 0 pages behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shouldAutoContinue takes the pages-behind value read at the start of the pass and stops at 0; nil keeps the existing behaviour. Apple re-reads GET_DATA_RANGE before each 5/MG continuation pass, as the official app does before every history round (observation from LeonardSEO/wearable5.0 FINDINGS §10). Not tested on a strap. Refs #1992 --- Strand/BLE/BLEManager.swift | 23 ++++++++++++++++++- StrandTests/BackfillContinuationTests.swift | 16 +++++++++++++ .../main/java/com/noop/ble/WhoopBleClient.kt | 14 +++++++++++ .../com/noop/ble/BackfillContinuationTest.kt | 16 +++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 690e394194..25193bcbb1 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -423,6 +423,7 @@ struct BackfillContinuation { persistedSensorRows: Bool = false, lastTrimAdvanced: Bool, consecutiveCount: Int, + strapPagesBehind: Int? = nil, maxAutoContinues: Int = defaultMaxAutoContinues, behindGapSeconds: Int = defaultBehindGapSeconds, futureSkewSeconds: Int = defaultFutureSkewSeconds) -> Bool { @@ -444,6 +445,10 @@ struct BackfillContinuation { // Stop and let the periodic floor retry; a real backlog pass persists new rows so this only bites the // empty/dup spin. guard persistedSensorRows else { return false } + // 3c (#1992): the strap's own ring backlog, read by GET_DATA_RANGE at the start of THIS pass, says + // nothing is pending: stop. A 5/MG reply carries no timestamp, so 2a can never fire there and 2b + // alone would keep re-kicking while rows persist. nil = not read this pass = unchanged behaviour. + if strapPagesBehind == 0 { return false } // #928: a strap clock set in the FUTURE makes "newest" read ahead of ANY real frontier, so 2a // would report backlog forever and drive up to the full cap in EMPTY offloads on every connect. // A newest more than futureSkewSeconds past the wall clock is implausible: exclude it from 2a. @@ -882,6 +887,9 @@ public final class BLEManager: NSObject, ObservableObject { /// (its else path, under the cap) and on disconnect — NOT unconditionally on every HISTORY_COMPLETE, /// so a strap that slices one offload into many completions can't reset the cap each slice (#25). private var consecutiveAutoContinues = 0 + /// #1992: GET_DATA_RANGE pages behind, read at the start of the current offload pass (5/MG re-polls it + /// on every auto-continue pass). nil until a reply lands; cleared on disconnect. + private var pagesBehindThisPass: Int? /// #battery: consecutive offload sessions that banked ZERO sensor rows — a clean HISTORY_COMPLETE-empty /// OR an idle-timeout STALL. Feeds BackfillPolicy's exponential backoff so the 15-min periodic poll /// stops spinning the radio on a strap returning nothing (a capture showed ~6 empty stalls/hour with no @@ -2575,6 +2583,12 @@ public final class BLEManager: NSObject, ObservableObject { state.r22FlagsAccepted = 0 state.deepPacketsThisSession = 0 historicalAckLogCounter = 0 + // #1992: like the official app, read the ring backlog before each 5/MG continuation pass so the + // auto-continue decision can see "0 pages behind". Read-only; the connect pass already has one. + if isWhoop5 && consecutiveAutoContinues > 0 { + pagesBehindThisPass = nil + send(.getDataRange) + } // Payload MUST be [0x00], NOT empty: verified on-device that this strap serves type-47 only with // [0x00] (empty → 0 frames on a clean stable link with ~2k records pending); the Mac ground-truth // offload (re/sync_openwhoop.py, re/diagnose_biometrics.py) uses [0x00] too. Plain offload — the @@ -3026,6 +3040,7 @@ public final class BLEManager: NSObject, ObservableObject { guard state.connected, state.bonded else { return } let newest = strapNewestTs let count = consecutiveAutoContinues + let pagesBehind = isWhoop5 ? pagesBehindThisPass : nil // #1992: 5/MG only until a 4.0 needs it Task { @MainActor in let frontier = await collector?.latestHRSampleTs() ?? nil let wallNow = Int(Date().timeIntervalSince1970) // #928: real wall clock, at decision time @@ -3069,7 +3084,11 @@ public final class BLEManager: NSObject, ObservableObject { wallNowUnix: wallNow, persistedSensorRows: persistedSensorRows, lastTrimAdvanced: trimAdvanced, - consecutiveCount: count) else { + consecutiveCount: count, + strapPagesBehind: pagesBehind) else { + if pagesBehind == 0, stillConnected, persistedSensorRows, trimAdvanced { + log("Backfill: not auto-continuing (#1992) - GET_DATA_RANGE reports 0 pages behind for this pass, so the strap ring is drained.") + } // #1012: name the stop honestly when the future-clock gate is what ended the chain — // without this line the log just goes quiet after one pass and a strap-log export can't // tell "caught up" from "future-dated range refused". Fires ONLY when 2b would otherwise @@ -6349,6 +6368,7 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { // #689/#815: the backlog sample is "at connect" by definition, so it must not survive the link it // was taken on — a stale figure under a fresh connection would be a plain lie. state.pagesBehindAtConnect = nil + pagesBehindThisPass = nil lastBatteryReadAt = nil // #battery: next connect's first enableLiveNotifications re-seeds the 5/MG battery reading // #612: the display flag only, not the underlying emptySyncTracker streak (that counter // deliberately survives a reconnect — unchanged, existing behaviour). A fresh link re-derives @@ -7207,6 +7227,7 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // #815: confirmed on both WHOOP 4.0 and 5.0/MG, so bank it unconditionally. The Today sync // chip reads this while backfilling is true. Twin of the Android FrameRouter branch. state.pagesBehindAtConnect = Int(pages) + pagesBehindThisPass = Int(pages) } else { log("Strap backlog pages behind: not decodable from this frame (#689 — offsets may have moved; " + "the raw frame above is the input). Diagnostic only, sync is unaffected.") diff --git a/StrandTests/BackfillContinuationTests.swift b/StrandTests/BackfillContinuationTests.swift index e3bbe3f9bf..4f80a7da98 100644 --- a/StrandTests/BackfillContinuationTests.swift +++ b/StrandTests/BackfillContinuationTests.swift @@ -428,6 +428,22 @@ final class BackfillContinuationTests: XCTestCase { consecutiveCount: 0)) } + // MARK: - #1992 strap ring backlog + + /// A 5/MG reply has no timestamp, so only 2b (rows persisted) drives it. A pass whose GET_DATA_RANGE + /// read 0 pages behind stops; any backlog, or no reading at all, keeps today's behaviour. + func testZeroPagesBehindStopsWhoop5Drain() { + func cont(_ pages: Int?) -> Bool { + BackfillContinuation.shouldAutoContinue( + stillConnected: true, strapNewestTs: nil, ourFrontierTs: wallNow - 3600, + wallNowUnix: wallNow, persistedSensorRows: true, lastTrimAdvanced: true, + consecutiveCount: 1, strapPagesBehind: pages) + } + XCTAssertFalse(cont(0)) + XCTAssertTrue(cont(12)) + XCTAssertTrue(cont(nil)) + } + // MARK: - #1598 clock-correlation family gate /// A 5/MG must never chase or DERIVE a GET_CLOCK correlation: its records already carry real-unix 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..6cc64d36a2 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -2047,6 +2047,7 @@ class WhoopBleClient( lastTrimAdvanced: Boolean, consecutiveCount: Int, persistedSensorRows: Boolean = false, + strapPagesBehind: Int? = null, maxAutoContinues: Int = MAX_AUTO_CONTINUES, behindGapSeconds: Long = AUTO_CONTINUE_BEHIND_GAP_SECONDS, futureSkewSeconds: Long = AUTO_CONTINUE_FUTURE_SKEW_SECONDS, @@ -2069,6 +2070,10 @@ class WhoopBleClient( // banks 0 NEW rows — it must also stop, not spin on already-synced data). Stop and let the 15-min // floor retry; a real backlog pass persists new rows so this only bites the empty/dup spin. if (!persistedSensorRows) return false + // 3c (#1992): the strap's own ring backlog, read by GET_DATA_RANGE at the start of THIS pass, says + // nothing is pending: stop. A 5/MG reply carries no timestamp, so 2a can never fire there and 2b + // alone would keep re-kicking while rows persist. null = not read this pass = unchanged behaviour. + if (strapPagesBehind == 0) return false // #928: a strap clock set in the FUTURE makes "newest" read ahead of ANY real frontier, so 2a // would report backlog forever and drive up to the full cap in EMPTY offloads on every // connect. A newest more than [futureSkewSeconds] past [wallNowUnix] (the REAL wall clock, @@ -3568,6 +3573,8 @@ class WhoopBleClient( * many completions can't reset the cap each slice (#25). Main-looper only. Mirrors Swift * `consecutiveAutoContinues`. */ private var consecutiveAutoContinues = 0 + /** #1992: GET_DATA_RANGE pages behind, read by the 5/MG gate at the start of the current pass. */ + private var pagesBehindThisPass: Int? = null /** #battery: consecutive offload sessions that handed over ZERO sensor rows — whether a clean * HISTORY_COMPLETE-empty OR an idle-timeout STALL (`result=stalled … rows=0`). Feeds BackfillPolicy's @@ -8328,6 +8335,7 @@ class WhoopBleClient( // #815: confirmed on both WHOOP 4.0 and 5.0/MG, so bank it unconditionally. // The Today sync chip reads this while backfilling is true. _state.update { it.copy(pagesBehindAtConnect = pagesBehind.toInt()) } + pagesBehindThisPass = pagesBehind.toInt() } else { log( "Strap backlog pages behind: not decodable from this frame (#689 — offsets may " + @@ -10946,6 +10954,7 @@ class WhoopBleClient( // hardware sometimes swallows the first GET_DATA_RANGE entirely, so a 2s fallback fires // the transfer anyway — the gate can delay the kick but never block it. WHOOP4 keeps its // proven blind-fire path untouched. + pagesBehindThisPass = null send(CommandNumber.GET_DATA_RANGE, byteArrayOf(), withResponse = true) handler.postDelayed({ if (backfilling && !historicalKickSent) { @@ -11498,6 +11507,7 @@ class WhoopBleClient( if (!s.connected || !s.bonded) return val newest = strapNewestTs val count = consecutiveAutoContinues + val pagesBehind = if (connectedFamily == DeviceFamily.WHOOP5) pagesBehindThisPass else null // #1992 ioScope.launch { val frontier = runCatching { repository.latestHrSampleTs(deviceId) }.getOrNull() val wallNow = System.currentTimeMillis() / 1000L // #928: real wall clock, at decision time @@ -11549,8 +11559,12 @@ class WhoopBleClient( lastTrimAdvanced = trimAdvanced, consecutiveCount = count, persistedSensorRows = persistedSensorRows, + strapPagesBehind = pagesBehind, ) ) { + if (pagesBehind == 0 && stillConnected && persistedSensorRows && trimAdvanced) { + log("Backfill: not auto-continuing (#1992) - GET_DATA_RANGE reports 0 pages behind for this pass, so the strap ring is drained.") + } // #1012: name the stop honestly when the future-clock gate is what ended the chain — // without this line the log just goes quiet after one pass and a strap-log export can't // tell "caught up" from "future-dated range refused". Fires ONLY when 2b would otherwise diff --git a/android/app/src/test/java/com/noop/ble/BackfillContinuationTest.kt b/android/app/src/test/java/com/noop/ble/BackfillContinuationTest.kt index 49396b91eb..0a02773733 100644 --- a/android/app/src/test/java/com/noop/ble/BackfillContinuationTest.kt +++ b/android/app/src/test/java/com/noop/ble/BackfillContinuationTest.kt @@ -513,6 +513,22 @@ class BackfillContinuationTest { ) } + // ---- #1992 strap ring backlog ---- + + /** A pass whose GET_DATA_RANGE read 0 pages behind stops; any backlog, or no reading, is unchanged. + * Twin of the Swift `testZeroPagesBehindStopsWhoop5Drain`. */ + @Test + fun zeroPagesBehind_stopsWhoop5Drain() { + fun cont(pages: Int?) = WhoopBleClient.shouldAutoContinue( + stillConnected = true, strapNewestTs = null, ourFrontierTs = wallNow - 3600L, + wallNowUnix = wallNow, lastTrimAdvanced = true, consecutiveCount = 1, + persistedSensorRows = true, strapPagesBehind = pages, + ) + assertFalse(cont(0)) + assertTrue(cont(12)) + assertTrue(cont(null)) + } + // ---- #1598 clock-correlation family gate ---- /**