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
23 changes: 22 additions & 1 deletion Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.")
Expand Down
16 changes: 16 additions & 0 deletions StrandTests/BackfillContinuationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 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 @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 " +
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions android/app/src/test/java/com/noop/ble/BackfillContinuationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----

/**
Expand Down