Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
106 changes: 62 additions & 44 deletions whoop-ble/src/main/kotlin/com/sploot/whoopble/gatt/WhoopGattManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ 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
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
Expand Down Expand Up @@ -116,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<Int>()
private var loggedR11FallbackIgnore = false
private var loggedObservedR11Companion = false
Expand Down Expand Up @@ -148,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)
Expand Down Expand Up @@ -253,6 +258,10 @@ class WhoopGattManager @Inject constructor(
realtimeEnableIssued = false
historicalFramesSeen = 0
historicalRecordsDecoded = 0
historicalSyncRunId++
historicalIdleFrameCount = 0
historicalIdleCompletionJob?.cancel()
historicalIdleCompletionJob = null
recentProtocolContext.clear()

connectionDeferred = CompletableDeferred()
Expand Down Expand Up @@ -287,21 +296,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() }
Expand All @@ -327,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
Expand Down Expand Up @@ -403,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()
}
Expand Down Expand Up @@ -438,6 +444,7 @@ class WhoopGattManager @Inject constructor(
}
if (sessionMode == WhoopSessionMode.HISTORICAL_SYNC && packetType == 0x2F) {
historicalFramesSeen++
scheduleHistoricalIdleCompletion()
}

if (packetType == 0x24) {
Expand Down Expand Up @@ -492,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) {
Expand Down Expand Up @@ -550,7 +556,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)}",
Expand All @@ -563,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")
Expand Down Expand Up @@ -601,13 +605,49 @@ 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")
}
}
}

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()
Expand Down Expand Up @@ -1069,30 +1109,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.
*
Expand Down Expand Up @@ -1132,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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
)
}
}
Original file line number Diff line number Diff line change
@@ -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) }
}
Loading