From 6d4f37148bc33709a35a1ccbc698c74544e6c52e Mon Sep 17 00:00:00 2001 From: BarMal Date: Sat, 20 Jun 2026 09:04:33 +0100 Subject: [PATCH] Handle WHOOP history disconnect retries --- .../app/service/WhoopRecordingService.kt | 42 ++++++++++++++- .../sploot/app/ui/screen/DashboardScreen.kt | 15 ++++++ .../app/ui/screen/DashboardViewModel.kt | 14 ++++- .../sploot/whoopble/gatt/WhoopGattManager.kt | 52 +++++++++++++++---- 4 files changed, 112 insertions(+), 11 deletions(-) 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 fddd549..eae9e43 100644 --- a/app/src/main/kotlin/com/sploot/app/service/WhoopRecordingService.kt +++ b/app/src/main/kotlin/com/sploot/app/service/WhoopRecordingService.kt @@ -201,6 +201,7 @@ class WhoopRecordingService : Service() { } catch (e: Exception) { startupMode = null Timber.e(e, "Recording session failed") + cleanupFailedSession(reason = "recording failed", wasHistoricalSync = false) whoopRuntimeCoordinator.setState(WhoopRuntimeState.ERROR) stopSelf() } @@ -247,7 +248,7 @@ class WhoopRecordingService : Service() { } catch (e: Exception) { startupMode = null Timber.e(e, "Historical sync failed") - whoopSyncCoordinator.markSyncFinished() + cleanupFailedSession(reason = "historical sync failed", wasHistoricalSync = true) whoopRuntimeCoordinator.setState(WhoopRuntimeState.ERROR) stopSelf() } @@ -312,6 +313,45 @@ class WhoopRecordingService : Service() { // ── BLE connection with exponential backoff ─────────────────────────────── + private suspend fun cleanupFailedSession( + reason: String, + wasHistoricalSync: Boolean, + ) { + pendingHistoricalSyncAfterStop = false + pendingLiveRestartAfterStop = false + startupMode = null + recordCollectorJob?.cancel() + recordCollectorJob = null + unknownCollectorJob?.cancel() + unknownCollectorJob = null + runCatching { gattManager.disableActiveStreams() } + .onFailure { Timber.w(it, "Failed to disable WHOOP streams after $reason") } + gattManager.disconnect() + + val sessionId = currentSessionId + if (sessionId >= 0L) { + recordingRepo.endSession(sessionId) + val hasAnyData = recordingRepo.sessionHasAnyData(sessionId) + if (!hasAnyData) { + recordingRepo.deleteSession(sessionId) + Timber.i("Dropped empty WHOOP session $sessionId after $reason") + } else { + Timber.i("Closed WHOOP session $sessionId after $reason") + enqueueProcessingWorkers(sessionId) + } + if (wasHistoricalSync) { + logHistoricalSummary(sessionId) + } + } + + currentSessionId = -1L + syncCutoffSeconds = null + if (wasHistoricalSync) { + whoopSyncCoordinator.markSyncFinished() + } + stopForeground(STOP_FOREGROUND_REMOVE) + } + private suspend fun connectWithRetry( mode: WhoopSessionMode, afterConnected: suspend () -> Unit = {}, diff --git a/app/src/main/kotlin/com/sploot/app/ui/screen/DashboardScreen.kt b/app/src/main/kotlin/com/sploot/app/ui/screen/DashboardScreen.kt index 2ca762b..0a8fdc0 100644 --- a/app/src/main/kotlin/com/sploot/app/ui/screen/DashboardScreen.kt +++ b/app/src/main/kotlin/com/sploot/app/ui/screen/DashboardScreen.kt @@ -227,6 +227,21 @@ fun DashboardScreen( QuickActionsRow(nav = nav) } + item { + TrendCard( + title = "Heart Rate Trend", + subtitle = "Average WHOOP heart rate by day", + accent = Color(0xFFF86D74), + points = state.heartRateTrend, + yAxis = TrendYAxis( + min = 40f, + max = 200f, + ticks = listOf(200f, 160f, 120f, 80f, 40f), + suffix = "", + ), + ) + } + item { TrendCard( title = "Sleep Score Trend", diff --git a/app/src/main/kotlin/com/sploot/app/ui/screen/DashboardViewModel.kt b/app/src/main/kotlin/com/sploot/app/ui/screen/DashboardViewModel.kt index 218ddc4..f87b92a 100644 --- a/app/src/main/kotlin/com/sploot/app/ui/screen/DashboardViewModel.kt +++ b/app/src/main/kotlin/com/sploot/app/ui/screen/DashboardViewModel.kt @@ -76,6 +76,7 @@ data class DashboardUiState( val totalActivityHours: Float = 0f, val totalCalories: Float = 0f, val sleepScoreTrend: List = emptyList(), + val heartRateTrend: List = emptyList(), val hrvTrend: List = emptyList(), val activityTrend: List = emptyList(), val isLoading: Boolean = true, @@ -324,7 +325,8 @@ class DashboardViewModel @Inject constructor( .sortedBy { it.startEpochSeconds } val todaysActivities = activityRepo.getActivitiesInRange(startOfToday, nowSeconds) .sortedBy { it.startEpochSeconds } - val todaysHrSamples = recordingRepo.getHrSamplesSince(startOfToday) + val hrSamplesInRange = recordingRepo.getHrSamplesSince(fromSeconds) + val todaysHrSamples = hrSamplesInRange.filter { it.tsSeconds >= startOfToday } val todaysTempEvents = recordingRepo.getEventsByTypeSince(startOfToday, TEMP_EVENT_TYPE) val todaysRespEpochs = sleepRepo.getEpochsSince(startOfToday, SleepSource.ALGO) @@ -385,6 +387,16 @@ class DashboardViewModel @Inject constructor( .mapNotNull { sleep -> sleep.totalScore?.toFloat() } .averageNumberOrNull() }, + heartRateTrend = buildDailyTrend( + days = it.selectedRange.days, + fromSeconds = fromSeconds, + zone = zone, + ) { dayStart, dayEnd -> + hrSamplesInRange + .filter { sample -> sample.tsSeconds in dayStart until dayEnd } + .map { sample -> sample.hrBpm.toFloat() } + .averageNumberOrNull() + }, hrvTrend = buildDailyTrend( days = it.selectedRange.days, fromSeconds = fromSeconds, 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 20d1ec3..6ff96fa 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 @@ -114,6 +114,8 @@ class WhoopGattManager @Inject constructor( @Volatile private var batchCounter = 5 @Volatile private var pendingLiveStreamConfig = WhoopStreamConfig() @Volatile private var realtimeEnableIssued = false + @Volatile private var historicalFramesSeen = 0 + @Volatile private var historicalRecordsDecoded = 0 private val observedSecondaryRecordTypes = mutableSetOf() private var loggedR11FallbackIgnore = false private var loggedObservedR11Companion = false @@ -138,14 +140,25 @@ class WhoopGattManager @Inject constructor( BluetoothProfile.STATE_DISCONNECTED -> { _state.value = ConnectionState.DISCONNECTED val disconnectException = IOException("GATT disconnected during WHOOP operation (status=$status)") - if (!connectionDeferred.isCompleted) { - connectionDeferred.completeExceptionally(disconnectException) - } - if (!liveReadyDeferred.isCompleted) { - liveReadyDeferred.completeExceptionally(disconnectException) - } - if (!historicalSyncDeferred.isCompleted) { - historicalSyncDeferred.completeExceptionally(disconnectException) + if (sessionMode == WhoopSessionMode.HISTORICAL_SYNC && + historicalFramesSeen > 0 && + !historicalSyncDeferred.isCompleted + ) { + traceInternal( + channel = "metadata", + summary = "Historical sync ended by strap disconnect after $historicalFramesSeen frames, decoded=$historicalRecordsDecoded", + ) + historicalSyncDeferred.complete(Unit) + } else { + if (!connectionDeferred.isCompleted) { + connectionDeferred.completeExceptionally(disconnectException) + } + if (!liveReadyDeferred.isCompleted) { + liveReadyDeferred.completeExceptionally(disconnectException) + } + if (!historicalSyncDeferred.isCompleted) { + historicalSyncDeferred.completeExceptionally(disconnectException) + } } dataAssembler.reset() eventAssembler.reset() @@ -238,6 +251,8 @@ class WhoopGattManager @Inject constructor( this.sessionMode = sessionMode pendingLiveStreamConfig = streamConfig realtimeEnableIssued = false + historicalFramesSeen = 0 + historicalRecordsDecoded = 0 recentProtocolContext.clear() connectionDeferred = CompletableDeferred() @@ -310,6 +325,8 @@ class WhoopGattManager @Inject constructor( sessionMode = WhoopSessionMode.LIVE_RECORDING pendingLiveStreamConfig = WhoopStreamConfig() realtimeEnableIssued = false + historicalFramesSeen = 0 + historicalRecordsDecoded = 0 observedSecondaryRecordTypes.clear() loggedR11FallbackIgnore = false loggedObservedR11Companion = false @@ -419,6 +436,9 @@ class WhoopGattManager @Inject constructor( if (handleMetadataFrame(frame)) { continue } + if (sessionMode == WhoopSessionMode.HISTORICAL_SYNC && packetType == 0x2F) { + historicalFramesSeen++ + } if (packetType == 0x24) { traceCommandResponse(frame) @@ -460,7 +480,21 @@ class WhoopGattManager @Inject constructor( else -> null } - record?.let { _records.tryEmit(it) } + record?.let { + if (sessionMode == WhoopSessionMode.HISTORICAL_SYNC && packetType == 0x2F) { + historicalRecordsDecoded++ + } + _records.tryEmit(it) + } + if ( + sessionMode == WhoopSessionMode.HISTORICAL_SYNC && + !historicalSyncDeferred.isCompleted && + (packetType == 0x28 || packetType == 0x2B) + ) { + traceInternal("metadata", "Historical sync implicitly complete after realtime frame") + historicalSyncDeferred.complete(Unit) + _state.value = ConnectionState.READY + } if (packetType == 0x30) { if (record == null) { val eventType = eventType(frame)