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 @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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 = {},
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/kotlin/com/sploot/app/ui/screen/DashboardScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ data class DashboardUiState(
val totalActivityHours: Float = 0f,
val totalCalories: Float = 0f,
val sleepScoreTrend: List<DashboardTrendPoint> = emptyList(),
val heartRateTrend: List<DashboardTrendPoint> = emptyList(),
val hrvTrend: List<DashboardTrendPoint> = emptyList(),
val activityTrend: List<DashboardTrendPoint> = emptyList(),
val isLoading: Boolean = true,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>()
private var loggedR11FallbackIgnore = false
private var loggedObservedR11Companion = false
Expand All @@ -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()
Expand Down Expand Up @@ -238,6 +251,8 @@ class WhoopGattManager @Inject constructor(
this.sessionMode = sessionMode
pendingLiveStreamConfig = streamConfig
realtimeEnableIssued = false
historicalFramesSeen = 0
historicalRecordsDecoded = 0
recentProtocolContext.clear()

connectionDeferred = CompletableDeferred()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading