From d4a107ca868a422772a33831c0a2ba6434c02052 Mon Sep 17 00:00:00 2001 From: BarMal Date: Sat, 20 Jun 2026 09:56:40 +0100 Subject: [PATCH 1/3] Align WHOOP history handshake with references --- .../sploot/whoopble/gatt/WhoopGattManager.kt | 44 +++---------------- .../whoopble/protocol/WhoopConstants.kt | 6 +++ .../protocol/WhoopHistoricalProtocol.kt | 33 ++++++++++++++ .../whoopble/WhoopHistoricalProtocolTest.kt | 23 ++++++++++ 4 files changed, 69 insertions(+), 37 deletions(-) create mode 100644 whoop-ble/src/main/kotlin/com/sploot/whoopble/protocol/WhoopHistoricalProtocol.kt create mode 100644 whoop-ble/src/test/kotlin/com/sploot/whoopble/WhoopHistoricalProtocolTest.kt diff --git a/whoop-ble/src/main/kotlin/com/sploot/whoopble/gatt/WhoopGattManager.kt b/whoop-ble/src/main/kotlin/com/sploot/whoopble/gatt/WhoopGattManager.kt index 6ff96fa..99f0ef8 100644 --- a/whoop-ble/src/main/kotlin/com/sploot/whoopble/gatt/WhoopGattManager.kt +++ b/whoop-ble/src/main/kotlin/com/sploot/whoopble/gatt/WhoopGattManager.kt @@ -22,6 +22,7 @@ import com.sploot.whoopble.protocol.R12Decoder import com.sploot.whoopble.protocol.R21Decoder import com.sploot.whoopble.protocol.RealtimeHrSummaryDecoder import com.sploot.whoopble.protocol.WhoopConstants +import com.sploot.whoopble.protocol.WhoopHistoricalProtocol import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -287,21 +288,14 @@ class WhoopGattManager @Inject constructor( val cmdChar = gatt!!.findCharacteristic(WhoopConstants.CMD_TO_STRAP_PREFIX) ?: throw IOException("CMD_TO_STRAP characteristic not found") - val initPackets = WhoopConstants.INIT_PACKETS - val historyRequestPacket = initPackets.last() - - // Keep SET_CLOCK out of the realtime-enable sequence. The strap may emit - // the live sync boundary immediately after SEND_HISTORICAL_DATA, which can - // otherwise race and interleave SET_CLOCK between R10/R11 and R21 enables. - for (packet in initPackets.dropLast(1)) { + // Whoopsie documents this Gen4 startup as a strict five-packet sequence. + // Do not interleave SET_CLOCK before SEND_HISTORICAL_DATA; some straps stop + // the historical transfer after the unexpected command. + for (packet in WhoopConstants.INIT_PACKETS) { writeGatt(cmdChar, packet) delay(50L) } - syncClock(cmdChar) - - writeGatt(cmdChar, historyRequestPacket) - if (sessionMode == WhoopSessionMode.LIVE_RECORDING) { _state.value = ConnectionState.CONNECTED withTimeout(180_000L) { liveReadyDeferred.await() } @@ -550,7 +544,7 @@ class WhoopGattManager @Inject constructor( frame[3] == 0xAB.toByte() && frame[4] == 0x31.toByte() ) { - val batchNumber = frame.copyOfRange(17, 21) + val batchNumber = frame.copyOfRange(17, 25) traceInternal( "metadata", "Historical batch marker received batch=${frame.getUInt32LE(17)}", @@ -601,7 +595,7 @@ class WhoopGattManager @Inject constructor( val localGatt = gatt ?: return@launch val cmdChar = localGatt.findCharacteristic(WhoopConstants.CMD_TO_STRAP_PREFIX) ?: return@launch - writeGatt(cmdChar, buildBatchAckPacket(batchCounter++, batchNumber)) + writeGatt(cmdChar, WhoopHistoricalProtocol.buildBatchAckPacket(batchCounter++, batchNumber)) }.onFailure { Timber.w(it, "Failed to ACK historical batch marker") } @@ -1069,30 +1063,6 @@ class WhoopGattManager @Inject constructor( private fun Int.toHexByte(): String = "%02x".format(this and 0xFF) - private fun buildBatchAckPacket(counter: Int, batchNumber: ByteArray): ByteArray { - val inner = byteArrayOf( - WhoopConstants.CMD_TYPE_COMMAND.toByte(), - counter.toByte(), - 0x17, - 0x01, - ) + batchNumber.copyOf(4) + byteArrayOf(0x00, 0x00, 0x00, 0x00) - - val frameLength = inner.size + 4 - val lenLo = (frameLength and 0xFF).toByte() - val lenHi = ((frameLength shr 8) and 0xFF).toByte() - val hdrCrc = CrcValidator.crc8(byteArrayOf(lenLo, lenHi)) - val crc32 = CrcValidator.crc32(inner) - - return byteArrayOf(WhoopConstants.SOF, lenLo, lenHi, hdrCrc) + - inner + - byteArrayOf( - (crc32 and 0xFF).toByte(), - ((crc32 shr 8) and 0xFF).toByte(), - ((crc32 shr 16) and 0xFF).toByte(), - ((crc32 ushr 24) and 0xFF).toByte(), - ) - } - /** * Build a command frame ready to write to CMD_TO_STRAP. * diff --git a/whoop-ble/src/main/kotlin/com/sploot/whoopble/protocol/WhoopConstants.kt b/whoop-ble/src/main/kotlin/com/sploot/whoopble/protocol/WhoopConstants.kt index 7ffb685..d5ef209 100644 --- a/whoop-ble/src/main/kotlin/com/sploot/whoopble/protocol/WhoopConstants.kt +++ b/whoop-ble/src/main/kotlin/com/sploot/whoopble/protocol/WhoopConstants.kt @@ -107,6 +107,12 @@ object WhoopConstants { /** Return device info including battery, RTC, firmware, and wrist state. */ const val CMD_GET_HELLO_HARVARD: Int = 0x23 + /** Start historical data transfer. */ + const val CMD_SEND_HISTORICAL_DATA: Int = 0x16 + + /** ACK a historical batch marker. */ + const val CMD_HISTORICAL_DATA_RESULT: Int = 0x17 + /** Enables R10 (HR + IMU) streaming. Payload: [0x01]. */ const val CMD_ENABLE_IMU: Int = 0x3F diff --git a/whoop-ble/src/main/kotlin/com/sploot/whoopble/protocol/WhoopHistoricalProtocol.kt b/whoop-ble/src/main/kotlin/com/sploot/whoopble/protocol/WhoopHistoricalProtocol.kt new file mode 100644 index 0000000..50c26ed --- /dev/null +++ b/whoop-ble/src/main/kotlin/com/sploot/whoopble/protocol/WhoopHistoricalProtocol.kt @@ -0,0 +1,33 @@ +package com.sploot.whoopble.protocol + +object WhoopHistoricalProtocol { + private const val BATCH_ID_BYTES = 8 + + fun buildBatchAckPacket(counter: Int, batchNumber: ByteArray): ByteArray { + require(batchNumber.size >= BATCH_ID_BYTES) { + "Historical batch ACK requires an 8-byte batch number" + } + + val inner = byteArrayOf( + WhoopConstants.CMD_TYPE_COMMAND.toByte(), + counter.toByte(), + WhoopConstants.CMD_HISTORICAL_DATA_RESULT.toByte(), + 0x01, + ) + batchNumber.copyOf(BATCH_ID_BYTES) + + val frameLength = inner.size + 4 + val lenLo = (frameLength and 0xFF).toByte() + val lenHi = ((frameLength shr 8) and 0xFF).toByte() + val hdrCrc = CrcValidator.crc8(byteArrayOf(lenLo, lenHi)) + val crc32 = CrcValidator.crc32(inner) + + return byteArrayOf(WhoopConstants.SOF, lenLo, lenHi, hdrCrc) + + inner + + byteArrayOf( + (crc32 and 0xFF).toByte(), + ((crc32 shr 8) and 0xFF).toByte(), + ((crc32 shr 16) and 0xFF).toByte(), + ((crc32 ushr 24) and 0xFF).toByte(), + ) + } +} diff --git a/whoop-ble/src/test/kotlin/com/sploot/whoopble/WhoopHistoricalProtocolTest.kt b/whoop-ble/src/test/kotlin/com/sploot/whoopble/WhoopHistoricalProtocolTest.kt new file mode 100644 index 0000000..6353284 --- /dev/null +++ b/whoop-ble/src/test/kotlin/com/sploot/whoopble/WhoopHistoricalProtocolTest.kt @@ -0,0 +1,23 @@ +package com.sploot.whoopble + +import com.sploot.whoopble.protocol.CrcValidator +import com.sploot.whoopble.protocol.WhoopHistoricalProtocol +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WhoopHistoricalProtocolTest { + + @Test + fun `batch ack uses all eight batch id bytes`() { + val batchId = byteArrayOf(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08) + + val packet = WhoopHistoricalProtocol.buildBatchAckPacket(counter = 5, batchNumber = batchId) + + assertEquals("aa100057230517010102030405060708", packet.copyOf(16).toHex()) + assertTrue("Header CRC8 invalid", CrcValidator.validateHeader(packet)) + assertTrue("Content CRC32 invalid", CrcValidator.validateContent(packet)) + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it.toInt() and 0xFF) } +} From 685cab3e05c26e2f92d7887367973db6ae8e1586 Mon Sep 17 00:00:00 2001 From: BarMal Date: Sat, 20 Jun 2026 10:42:10 +0100 Subject: [PATCH 2/3] Treat idle WHOOP history as sync completion --- .../sploot/whoopble/gatt/WhoopGattManager.kt | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/whoop-ble/src/main/kotlin/com/sploot/whoopble/gatt/WhoopGattManager.kt b/whoop-ble/src/main/kotlin/com/sploot/whoopble/gatt/WhoopGattManager.kt index 99f0ef8..188bb58 100644 --- a/whoop-ble/src/main/kotlin/com/sploot/whoopble/gatt/WhoopGattManager.kt +++ b/whoop-ble/src/main/kotlin/com/sploot/whoopble/gatt/WhoopGattManager.kt @@ -27,6 +27,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow @@ -117,6 +118,9 @@ class WhoopGattManager @Inject constructor( @Volatile private var realtimeEnableIssued = false @Volatile private var historicalFramesSeen = 0 @Volatile private var historicalRecordsDecoded = 0 + @Volatile private var historicalSyncRunId = 0L + @Volatile private var historicalIdleFrameCount = 0 + private var historicalIdleCompletionJob: Job? = null private val observedSecondaryRecordTypes = mutableSetOf() private var loggedR11FallbackIgnore = false private var loggedObservedR11Companion = false @@ -149,7 +153,7 @@ class WhoopGattManager @Inject constructor( channel = "metadata", summary = "Historical sync ended by strap disconnect after $historicalFramesSeen frames, decoded=$historicalRecordsDecoded", ) - historicalSyncDeferred.complete(Unit) + completeHistoricalSync("strap disconnect", markReady = false) } else { if (!connectionDeferred.isCompleted) { connectionDeferred.completeExceptionally(disconnectException) @@ -254,6 +258,10 @@ class WhoopGattManager @Inject constructor( realtimeEnableIssued = false historicalFramesSeen = 0 historicalRecordsDecoded = 0 + historicalSyncRunId++ + historicalIdleFrameCount = 0 + historicalIdleCompletionJob?.cancel() + historicalIdleCompletionJob = null recentProtocolContext.clear() connectionDeferred = CompletableDeferred() @@ -321,6 +329,10 @@ class WhoopGattManager @Inject constructor( realtimeEnableIssued = false historicalFramesSeen = 0 historicalRecordsDecoded = 0 + historicalSyncRunId++ + historicalIdleFrameCount = 0 + historicalIdleCompletionJob?.cancel() + historicalIdleCompletionJob = null observedSecondaryRecordTypes.clear() loggedR11FallbackIgnore = false loggedObservedR11Companion = false @@ -397,7 +409,7 @@ class WhoopGattManager @Inject constructor( writeGatt(cmdChar, buildCommandPacket(nextSeq(), WhoopConstants.CMD_STOP_HAPTICS, byteArrayOf())) } - suspend fun awaitHistoricalSyncCompletion(timeoutMs: Long = 180_000L) { + suspend fun awaitHistoricalSyncCompletion(timeoutMs: Long = HISTORICAL_SYNC_TIMEOUT_MS) { withTimeout(timeoutMs) { historicalSyncDeferred.await() } @@ -432,6 +444,7 @@ class WhoopGattManager @Inject constructor( } if (sessionMode == WhoopSessionMode.HISTORICAL_SYNC && packetType == 0x2F) { historicalFramesSeen++ + scheduleHistoricalIdleCompletion() } if (packetType == 0x24) { @@ -486,8 +499,7 @@ class WhoopGattManager @Inject constructor( (packetType == 0x28 || packetType == 0x2B) ) { traceInternal("metadata", "Historical sync implicitly complete after realtime frame") - historicalSyncDeferred.complete(Unit) - _state.value = ConnectionState.READY + completeHistoricalSync("realtime frame") } if (packetType == 0x30) { if (record == null) { @@ -557,9 +569,7 @@ class WhoopGattManager @Inject constructor( if (frame[4] == 0x31.toByte()) { if (sessionMode == WhoopSessionMode.HISTORICAL_SYNC && !historicalSyncDeferred.isCompleted) { traceInternal("metadata", "Historical sync complete marker received") - appendProtocolContext("metadata:historical-complete size=${frame.size}") - historicalSyncDeferred.complete(Unit) - _state.value = ConnectionState.READY + completeHistoricalSync("metadata complete marker size=${frame.size}") Timber.i("Whoop historical sync complete") } else if (sessionMode == WhoopSessionMode.LIVE_RECORDING && !realtimeEnableIssued) { traceInternal("metadata", "Live end-of-sync marker received, enabling realtime streams") @@ -602,6 +612,42 @@ class WhoopGattManager @Inject constructor( } } + private fun scheduleHistoricalIdleCompletion() { + val runId = historicalSyncRunId + val frameCount = historicalFramesSeen + historicalIdleFrameCount = frameCount + historicalIdleCompletionJob?.cancel() + historicalIdleCompletionJob = managerScope.launch { + delay(HISTORICAL_IDLE_COMPLETION_MS) + if ( + sessionMode == WhoopSessionMode.HISTORICAL_SYNC && + historicalSyncRunId == runId && + !historicalSyncDeferred.isCompleted && + historicalFramesSeen > 0 && + historicalFramesSeen == frameCount && + historicalIdleFrameCount == frameCount + ) { + traceInternal( + channel = "metadata", + summary = "Historical sync complete after idle (${HISTORICAL_IDLE_COMPLETION_MS}ms, frames=$frameCount, decoded=$historicalRecordsDecoded)", + ) + completeHistoricalSync("historical data idle") + } + } + } + + private fun completeHistoricalSync(reason: String, markReady: Boolean = true) { + historicalIdleCompletionJob?.cancel() + historicalIdleCompletionJob = null + appendProtocolContext("metadata:historical-complete reason=$reason frames=$historicalFramesSeen decoded=$historicalRecordsDecoded") + if (!historicalSyncDeferred.isCompleted) { + historicalSyncDeferred.complete(Unit) + } + if (markReady) { + _state.value = ConnectionState.READY + } + } + private suspend fun enableNotify(characteristic: BluetoothGattCharacteristic) { writeMutex.withLock { writeDeferred = CompletableDeferred() @@ -1102,6 +1148,8 @@ class WhoopGattManager @Inject constructor( companion object { private const val DEFAULT_HARVARD_HAPTIC_PATTERN_ID = 2 + private const val HISTORICAL_IDLE_COMPLETION_MS = 12_000L + private const val HISTORICAL_SYNC_TIMEOUT_MS = 30 * 60_000L private const val RECENT_PROTOCOL_CONTEXT_LIMIT = 8 private const val MAX_PROTOCOL_CONTEXT_ENTRY_CHARS = 180 } From 90312ba5a87fa35e1b5443159c234a6923fee5a9 Mon Sep 17 00:00:00 2001 From: BarMal Date: Sat, 20 Jun 2026 11:11:22 +0100 Subject: [PATCH 3/3] Persist historical WHOOP samples out of order --- .../kotlin/com/sploot/app/service/WhoopRecordingService.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/sploot/app/service/WhoopRecordingService.kt b/app/src/main/kotlin/com/sploot/app/service/WhoopRecordingService.kt index eae9e43..d3628bf 100644 --- a/app/src/main/kotlin/com/sploot/app/service/WhoopRecordingService.kt +++ b/app/src/main/kotlin/com/sploot/app/service/WhoopRecordingService.kt @@ -724,7 +724,10 @@ class WhoopRecordingService : Service() { tsSeconds: Long, lastPersistedSecond: Long?, intervalSeconds: Int, - ): Boolean = lastPersistedSecond == null || (tsSeconds - lastPersistedSecond) >= intervalSeconds + ): Boolean = + currentMode == WhoopSessionMode.HISTORICAL_SYNC || + lastPersistedSecond == null || + (tsSeconds - lastPersistedSecond) >= intervalSeconds private suspend fun shouldSkipExistingHistoricalImu(tsSeconds: Long): Boolean = currentMode == WhoopSessionMode.HISTORICAL_SYNC &&