From c166c0ca2d1adc30e7caf19f0f1475659b084c27 Mon Sep 17 00:00:00 2001 From: BarMal Date: Sat, 20 Jun 2026 08:26:00 +0100 Subject: [PATCH] Stabilize WHOOP historical backfill --- .../app/service/WhoopRecordingService.kt | 18 ++++++++++--- .../app/ui/screen/DashboardViewModel.kt | 4 +++ .../kotlin/com/sploot/data/dao/RawImuDao.kt | 25 +++++++++++++++++++ .../data/repository/RecordingRepository.kt | 8 ++++++ .../sploot/whoopble/gatt/WhoopGattManager.kt | 10 ++++++++ 5 files changed, 61 insertions(+), 4 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 4df6585..8415fef 100644 --- a/app/src/main/kotlin/com/sploot/app/service/WhoopRecordingService.kt +++ b/app/src/main/kotlin/com/sploot/app/service/WhoopRecordingService.kt @@ -233,9 +233,14 @@ class WhoopRecordingService : Service() { recordCollectorJob = launch { collectRecords() } unknownCollectorJob?.cancel() unknownCollectorJob = launch { collectUnknownObservations() } - connectWithRetry(currentMode) - whoopRuntimeCoordinator.setState(WhoopRuntimeState.HISTORY) - gattManager.awaitHistoricalSyncCompletion() + connectWithRetry(currentMode) { + whoopRuntimeCoordinator.setState(WhoopRuntimeState.HISTORY) + gattManager.awaitHistoricalSyncCompletion() + } + val repairedHrSamples = recordingRepo.repairMissingHrSamplesFromRawImu() + if (repairedHrSamples > 0) { + Timber.i("Repaired $repairedHrSamples missing WHOOP HR samples from raw IMU") + } delay(1_000L) stopRecording() } catch (e: Exception) { @@ -302,7 +307,10 @@ class WhoopRecordingService : Service() { // ── BLE connection with exponential backoff ─────────────────────────────── - private suspend fun connectWithRetry(mode: WhoopSessionMode) { + private suspend fun connectWithRetry( + mode: WhoopSessionMode, + afterConnected: suspend () -> Unit = {}, + ) { var backoffMs = 2_000L repeat(6) { attempt -> @@ -322,9 +330,11 @@ class WhoopRecordingService : Service() { "WHOOP connected — recording" } ) + afterConnected() return } catch (e: Exception) { Timber.w(e, "Connect attempt $attempt failed, retry in ${backoffMs}ms") + gattManager.disconnect() if (attempt < 5) { delay(backoffMs) backoffMs = (backoffMs * 2).coerceAtMost(30_000L) 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 5849b1b..218ddc4 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 @@ -258,12 +258,16 @@ class DashboardViewModel @Inject constructor( private fun refreshWhoopHistoryState() { viewModelScope.launch { + val repairedHrSamples = recordingRepo.repairMissingHrSamplesFromRawImu() val latestWhoopTimestamp = recordingRepo.getLatestStoredTimestamp() _uiState.update { it.copy( latestWhoopDataTimestampSeconds = latestWhoopTimestamp, ) } + if (repairedHrSamples > 0) { + refreshAnalytics() + } } } diff --git a/data/src/main/kotlin/com/sploot/data/dao/RawImuDao.kt b/data/src/main/kotlin/com/sploot/data/dao/RawImuDao.kt index 2d02019..65214eb 100644 --- a/data/src/main/kotlin/com/sploot/data/dao/RawImuDao.kt +++ b/data/src/main/kotlin/com/sploot/data/dao/RawImuDao.kt @@ -26,4 +26,29 @@ interface RawImuDao { @Query("SELECT MAX(tsSeconds) FROM raw_imu") suspend fun getLatestTimestamp(): Long? + + @Query( + """ + INSERT OR IGNORE INTO hr_samples(sessionId, tsSeconds, hrBpm) + SELECT sessionId, tsSeconds, hrBpm + FROM raw_imu + WHERE hrBpm BETWEEN 30 AND 240 + """ + ) + suspend fun backfillMissingHrSamples() + + @Query( + """ + SELECT COUNT(*) + FROM raw_imu + WHERE hrBpm BETWEEN 30 AND 240 + AND NOT EXISTS ( + SELECT 1 + FROM hr_samples + WHERE hr_samples.sessionId = raw_imu.sessionId + AND hr_samples.tsSeconds = raw_imu.tsSeconds + ) + """ + ) + suspend fun countMissingHrSamples(): Int } diff --git a/data/src/main/kotlin/com/sploot/data/repository/RecordingRepository.kt b/data/src/main/kotlin/com/sploot/data/repository/RecordingRepository.kt index dfac00e..35658d7 100644 --- a/data/src/main/kotlin/com/sploot/data/repository/RecordingRepository.kt +++ b/data/src/main/kotlin/com/sploot/data/repository/RecordingRepository.kt @@ -186,6 +186,14 @@ class RecordingRepository @Inject constructor( eventDao.getLatestTimestamp(), ).maxOrNull() + suspend fun repairMissingHrSamplesFromRawImu(): Int { + val missingCount = imuDao.countMissingHrSamples() + if (missingCount > 0) { + imuDao.backfillMissingHrSamples() + } + return missingCount + } + suspend fun sessionHasAnyData(sessionId: Long): Boolean = imuDao.countForSession(sessionId) > 0 || ppgDao.countForSession(sessionId) > 0 || 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 333781a..b8339e4 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 @@ -137,6 +137,16 @@ 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) + } dataAssembler.reset() eventAssembler.reset() cmdAssembler.reset()