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
22 changes: 19 additions & 3 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2641,8 +2641,24 @@ public final class BLEManager: NSObject, ObservableObject {
}
}

/// Re-arm the idle watchdog. Called on every offload frame during backfill so the timer resets
/// as long as the strap keeps sending HISTORY; if the strap goes silent the timer fires and we
/// True when an offload frame is evidence that the strap is still handing over history, so the
/// backfill idle watchdog may re-arm on it. Every `isOffloadFrame` frame is still routed to the
/// Backfiller; this only decides which ones count as PROGRESS.
///
/// A WHOOP 4.0 EVENT frame (type 48) does not. The strap emits a fresh event roughly every 15 s
/// whether or not it is offloading, and when it answers SEND_HISTORICAL_DATA with PENDING but never
/// starts the type-47 stream, those events alone re-armed the 60 s watchdog forever: the session
/// neither completed nor timed out, and `backfilling` stayed true, so every later sync trigger was
/// refused for the life of the connection. A real offload streams type-47 records and closes chunks
/// with METADATA (49), both of which still re-arm. 5/MG is unchanged: its offload has not been
/// observed in this state, and its event cadence is unmeasured.
static func offloadFrameShowsProgress(_ frame: [UInt8], family: DeviceFamily) -> Bool {
guard isOffloadFrame(frame, family: family) else { return false }
return !(family == .whoop4 && frame[4] == 48)
}

/// Re-arm the idle watchdog. Called on every progress frame (`offloadFrameShowsProgress`) so the
/// timer resets as long as the strap keeps sending HISTORY; if the strap goes silent the timer fires and we
/// exit the session (the durable strap_trim cursor means the next session resumes where we left
/// off). Timeout is generous (60 s, not 20 s): the unstoppable ~2/s type-43 raw flood eats BLE
/// airtime, so genuine offload frames can arrive in bursts with multi-second lulls between chunks
Expand Down Expand Up @@ -7403,7 +7419,7 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// is a permanent loss. The verdict is formed once here, from the verifier, and
// handed to the counter — no parse on this path, which is why it was skipped.
router.noteOffloadFrameVerdict(verifyFrame(frame, family: .whoop4))
armBackfillTimeout()
if BLEManager.offloadFrameShowsProgress(frame, family: .whoop4) { armBackfillTimeout() }
routeBackfillFrame(frame)
// …but a REAL-TIME physical gesture (double-tap / wrist) must still fire even mid-
// offload (#69). Gated on ts≈now so replayed historical EVENTs (old ts) are ignored.
Expand Down
47 changes: 47 additions & 0 deletions StrandTests/OffloadProgressWatchdogTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import XCTest
import WhoopProtocol
@testable import Strand

/// Which offload frames may re-arm the backfill idle watchdog (`BLEManager.offloadFrameShowsProgress`).
///
/// A WHOOP 4.0 that answered SEND_HISTORICAL_DATA with PENDING and then sent only its periodic EVENT
/// frames kept the session open indefinitely, because every event re-armed the 60 s watchdog. These pin
/// that events no longer count as progress on a 4.0 while every genuine offload type still does, and
/// that 5/MG keeps its existing behaviour. Twin of the Android `OffloadProgressWatchdogTest`.
@MainActor
final class OffloadProgressWatchdogTests: XCTestCase {

private func frame(type: UInt8, at index: Int) -> [UInt8] {
var f = [UInt8](repeating: 0, count: 16)
f[index] = type
return f
}

func testWhoop4EventFrameIsNotProgress() {
XCTAssertTrue(BLEManager.isOffloadFrame(frame(type: 48, at: 4), family: .whoop4),
"an EVENT is still an offload frame and is still routed to the Backfiller")
XCTAssertFalse(BLEManager.offloadFrameShowsProgress(frame(type: 48, at: 4), family: .whoop4))
}

func testWhoop4HistoryTypesAreProgress() {
for t: UInt8 in [47, 49, 50, 56] {
XCTAssertTrue(BLEManager.offloadFrameShowsProgress(frame(type: t, at: 4), family: .whoop4),
"type \(t) must re-arm the watchdog")
}
}

func testLiveFramesAreNeverProgress() {
for t: UInt8 in [40, 43] {
XCTAssertFalse(BLEManager.offloadFrameShowsProgress(frame(type: t, at: 4), family: .whoop4))
XCTAssertFalse(BLEManager.offloadFrameShowsProgress(frame(type: t, at: 8), family: .whoop5))
}
}

func testWhoop5EventFrameIsUnchanged() {
XCTAssertTrue(BLEManager.offloadFrameShowsProgress(frame(type: 48, at: 8), family: .whoop5))
}

func testShortFrameIsNotProgress() {
XCTAssertFalse(BLEManager.offloadFrameShowsProgress([0xAA, 0x01, 0x00], family: .whoop4))
}
}
25 changes: 22 additions & 3 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1290,6 +1290,25 @@ class WhoopBleClient(
}
}

/**
* True when an offload frame is evidence that the strap is still handing over history, so the
* backfill idle watchdog may re-arm on it. Every [isOffloadFrame] frame is still routed to the
* drain; this only decides which ones count as PROGRESS.
*
* A WHOOP 4.0 EVENT frame (type 48) does not. The strap emits a fresh event roughly every 15 s
* whether or not it is offloading, and when it answers SEND_HISTORICAL_DATA with PENDING but
* never starts the type-47 stream, those events alone re-armed the 60 s watchdog forever: the
* session neither completed nor timed out, and `backfilling` stayed true, so every later sync
* trigger was refused for the life of the connection. A real offload streams type-47 records and
* closes chunks with METADATA (49), both of which still re-arm. 5/MG is unchanged: its offload
* has not been observed in this state, and its event cadence is unmeasured.
* Twin of Swift `BLEManager.offloadFrameShowsProgress`.
*/
fun offloadFrameShowsProgress(frame: ByteArray, family: DeviceFamily): Boolean {
if (!isOffloadFrame(frame, family)) return false
return !(family == DeviceFamily.WHOOP4 && (frame[4].toInt() and 0xFF) == 48)
}

/**
* The gate every offload kick passes through: a sync may start ONLY when the link is up
* ([connected]), the command channel is usable ([bonded]), and no offload is already running
Expand Down Expand Up @@ -8528,7 +8547,7 @@ class WhoopBleClient(
// it; feeding it only delays each chunk's insert->trim-ack and stalls the strap).
if (isOffloadFrame(frame, connectedFamily)) {
offloadFramesThisSession++
armBackfillTimeout()
if (offloadFrameShowsProgress(frame, connectedFamily)) armBackfillTimeout()
routeBackfillFrame(frame)
}
} else {
Expand Down Expand Up @@ -11093,8 +11112,8 @@ class WhoopBleClient(
}

/**
* Re-arm the idle watchdog. Called on every offload frame during backfill; if the strap goes
* silent the timer fires and we exit the session. Port of `armBackfillTimeout`.
* Re-arm the idle watchdog. Called on every progress frame ([offloadFrameShowsProgress]); if the
* strap goes silent the timer fires and we exit the session. Port of `armBackfillTimeout`.
*/
private fun armBackfillTimeout() {
handler.removeCallbacks(backfillTimeoutRunnable)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.noop.ble

import com.noop.protocol.DeviceFamily
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

/**
* Which offload frames may re-arm the backfill idle watchdog ([WhoopBleClient.offloadFrameShowsProgress]).
*
* A WHOOP 4.0 that answered SEND_HISTORICAL_DATA with PENDING and then sent only its periodic EVENT
* frames kept the session open indefinitely, because every event re-armed the 60 s watchdog. These pin
* that events no longer count as progress on a 4.0 while every genuine offload type still does, and
* that 5/MG keeps its existing behaviour. Twin of the Swift `OffloadProgressWatchdogTests`.
*/
class OffloadProgressWatchdogTest {

private fun frame(type: Int, at: Int): ByteArray = ByteArray(16).also { it[at] = type.toByte() }

@Test
fun whoop4EventFrameIsNotProgress() {
assertTrue("an EVENT is still an offload frame and is still routed to the drain",
WhoopBleClient.isOffloadFrame(frame(48, 4), DeviceFamily.WHOOP4))
assertFalse(WhoopBleClient.offloadFrameShowsProgress(frame(48, 4), DeviceFamily.WHOOP4))
}

@Test
fun whoop4HistoryTypesAreProgress() {
for (t in intArrayOf(47, 49, 50, 56)) {
assertTrue("type $t must re-arm the watchdog",
WhoopBleClient.offloadFrameShowsProgress(frame(t, 4), DeviceFamily.WHOOP4))
}
}

@Test
fun liveFramesAreNeverProgress() {
for (t in intArrayOf(40, 43)) {
assertFalse(WhoopBleClient.offloadFrameShowsProgress(frame(t, 4), DeviceFamily.WHOOP4))
assertFalse(WhoopBleClient.offloadFrameShowsProgress(frame(t, 8), DeviceFamily.WHOOP5))
}
}

@Test
fun whoop5EventFrameIsUnchanged() {
assertTrue(WhoopBleClient.offloadFrameShowsProgress(frame(48, 8), DeviceFamily.WHOOP5))
}

@Test
fun shortFrameIsNotProgress() {
assertFalse(WhoopBleClient.offloadFrameShowsProgress(byteArrayOf(0xAA.toByte(), 1, 0), DeviceFamily.WHOOP4))
}
}