diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 690e394194..a7c4ce8a42 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -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 @@ -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. diff --git a/StrandTests/OffloadProgressWatchdogTests.swift b/StrandTests/OffloadProgressWatchdogTests.swift new file mode 100644 index 0000000000..8b03390fa9 --- /dev/null +++ b/StrandTests/OffloadProgressWatchdogTests.swift @@ -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)) + } +} 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..0267a381eb 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -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 @@ -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 { @@ -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) diff --git a/android/app/src/test/java/com/noop/ble/OffloadProgressWatchdogTest.kt b/android/app/src/test/java/com/noop/ble/OffloadProgressWatchdogTest.kt new file mode 100644 index 0000000000..3080b620ef --- /dev/null +++ b/android/app/src/test/java/com/noop/ble/OffloadProgressWatchdogTest.kt @@ -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)) + } +}