From 25b23bec869c5d7ed4e01398379a02ff468595a3 Mon Sep 17 00:00:00 2001 From: BarMal Date: Fri, 5 Jun 2026 22:16:04 +0100 Subject: [PATCH] Fix WHOOP history sync and sleep gating --- .../app/service/WhoopRecordingService.kt | 124 +++++++++++++-- .../com/sploot/app/ui/debug/DebugScreen.kt | 17 ++ .../com/sploot/app/ui/debug/DebugViewModel.kt | 34 +++- .../sploot/app/ui/screen/DashboardScreen.kt | 145 +++++++++++++---- .../app/ui/screen/DashboardViewModel.kt | 29 +++- .../app/worker/SleepProcessingWorker.kt | 121 ++++++++++++--- .../kotlin/com/sploot/data/dao/HrSampleDao.kt | 3 + .../kotlin/com/sploot/data/dao/RawImuDao.kt | 3 + .../kotlin/com/sploot/data/dao/RawPpgDao.kt | 3 + .../com/sploot/data/dao/WhoopEventDao.kt | 3 + .../data/repository/RecordingRepository.kt | 15 ++ .../sploot/whoopble/gatt/WhoopGattManager.kt | 146 +++++++++++++++--- .../whoopble/model/WhoopUnknownObservation.kt | 1 + .../sploot/whoopble/protocol/R12Decoder.kt | 38 +++++ .../whoopble/protocol/WhoopConstants.kt | 9 ++ 15 files changed, 588 insertions(+), 103 deletions(-) create mode 100644 whoop-ble/src/main/kotlin/com/sploot/whoopble/protocol/R12Decoder.kt 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 30ca43c..4df6585 100644 --- a/app/src/main/kotlin/com/sploot/app/service/WhoopRecordingService.kt +++ b/app/src/main/kotlin/com/sploot/app/service/WhoopRecordingService.kt @@ -33,6 +33,7 @@ import com.sploot.whoopble.gatt.ConnectionState import com.sploot.whoopble.gatt.WhoopSessionMode import com.sploot.whoopble.gatt.WhoopStreamConfig import com.sploot.whoopble.gatt.WhoopGattManager +import com.sploot.whoopble.model.UnknownObservationCategory import com.sploot.whoopble.model.WhoopUnknownObservation import com.sploot.whoopble.model.WhoopRecord import com.sploot.whoopble.protocol.WhoopConstants @@ -44,6 +45,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber @@ -76,6 +78,11 @@ class WhoopRecordingService : Service() { private var lastPersistedPpgSecond: Long? = null private var lastPersistedHrSecond: Long? = null private var batterySaverActive = false + private var historicalImuPersisted = 0 + private var historicalPpgPersisted = 0 + private var historicalHrPersisted = 0 + private var historicalEventPersisted = 0 + private var historicalSkippedByCutoff = 0 private var currentMode = WhoopSessionMode.LIVE_RECORDING private var startupMode: WhoopSessionMode? = null private var syncCutoffSeconds: Long? = null @@ -126,6 +133,7 @@ class WhoopRecordingService : Service() { } override fun onDestroy() { + closeCurrentSessionIfNeeded(reason = "service destroyed") serviceScope.cancel() unregisterReceiver(powerSaveReceiver) gattManager.disconnect() @@ -155,7 +163,7 @@ class WhoopRecordingService : Service() { private fun handleStartHistoricalSync(autoStartLiveAfter: Boolean) { if (startupMode == WhoopSessionMode.HISTORICAL_SYNC) return whoopSyncCoordinator.markSyncStarted() - val shouldResumeLiveAfterSync = autoStartLiveAfter || currentMode == WhoopSessionMode.LIVE_RECORDING + val shouldResumeLiveAfterSync = autoStartLiveAfter if (currentSessionId >= 0L) { if (currentMode == WhoopSessionMode.HISTORICAL_SYNC) return pendingHistoricalSyncAfterStop = true @@ -202,6 +210,7 @@ class WhoopRecordingService : Service() { currentMode = WhoopSessionMode.HISTORICAL_SYNC startupMode = WhoopSessionMode.HISTORICAL_SYNC syncCutoffSeconds = null + resetHistoricalCounters() pendingLiveRestartAfterStop = autoStartLiveAfter whoopRuntimeCoordinator.setState(WhoopRuntimeState.STARTING_HISTORY) startForeground(NOTIF_ID, buildNotification("Syncing WHOOP history…")) @@ -266,11 +275,12 @@ class WhoopRecordingService : Service() { enqueueProcessingWorkers(sessionId) } } - currentSessionId = -1L - syncCutoffSeconds = null if (wasHistoricalSync) { + logHistoricalSummary(sessionId = currentSessionId) whoopSyncCoordinator.markSyncFinished() } + currentSessionId = -1L + syncCutoffSeconds = null when { restartHistorical -> { whoopRuntimeCoordinator.setState(WhoopRuntimeState.SWITCHING_TO_HISTORY) @@ -379,7 +389,7 @@ class WhoopRecordingService : Service() { is WhoopRecord.Imu -> { if (!settings.enableWhoopImuStream) return@collect val tsSeconds = record.timestamp.epochSecond - if (shouldSkipHistoricalRecord(tsSeconds)) return@collect + if (shouldSkipExistingHistoricalImu(tsSeconds)) return@collect if (shouldPersist(tsSeconds, lastPersistedImuSecond, settings.effectiveImuIntervalSeconds())) { recordingRepo.insertImu( sessionId = currentSessionId, @@ -394,6 +404,10 @@ class WhoopRecordingService : Service() { ) lastPersistedImuSecond = tsSeconds lastPersistedHrSecond = tsSeconds + if (currentMode == WhoopSessionMode.HISTORICAL_SYNC) { + historicalImuPersisted++ + historicalHrPersisted++ + } } else if ( settings.enableWhoopHrStream && shouldPersist(tsSeconds, lastPersistedHrSecond, settings.effectiveHrIntervalSeconds()) @@ -404,13 +418,16 @@ class WhoopRecordingService : Service() { hrBpm = record.hrBpm, ) lastPersistedHrSecond = tsSeconds + if (currentMode == WhoopSessionMode.HISTORICAL_SYNC) { + historicalHrPersisted++ + } } } is WhoopRecord.Ppg -> { if (!settings.enableWhoopPpgStream) return@collect val tsSeconds = record.timestamp.epochSecond - if (shouldSkipHistoricalRecord(tsSeconds)) return@collect + if (shouldSkipExistingHistoricalPpg(tsSeconds)) return@collect if (shouldPersist(tsSeconds, lastPersistedPpgSecond, settings.effectivePpgIntervalSeconds())) { recordingRepo.insertPpg( sessionId = currentSessionId, @@ -424,13 +441,16 @@ class WhoopRecordingService : Service() { channelF = record.channelF.toUInt16Bytes(), ) lastPersistedPpgSecond = tsSeconds + if (currentMode == WhoopSessionMode.HISTORICAL_SYNC) { + historicalPpgPersisted++ + } } } is WhoopRecord.HeartRate -> { if (!settings.enableWhoopHrStream) return@collect val tsSeconds = record.timestamp.epochSecond - if (shouldSkipHistoricalRecord(tsSeconds)) return@collect + if (shouldSkipExistingHistoricalHr(tsSeconds)) return@collect if (shouldPersist(tsSeconds, lastPersistedHrSecond, settings.effectiveHrIntervalSeconds())) { recordingRepo.insertHrSample( sessionId = currentSessionId, @@ -438,85 +458,96 @@ class WhoopRecordingService : Service() { hrBpm = record.hrBpm, ) lastPersistedHrSecond = tsSeconds + if (currentMode == WhoopSessionMode.HISTORICAL_SYNC) { + historicalHrPersisted++ + } } } is WhoopRecord.Battery -> if (settings.captureWhoopEvents) { val tsSeconds = record.timestamp.epochSecond - if (!shouldSkipHistoricalRecord(tsSeconds)) { + if (!shouldSkipExistingHistoricalEvent(tsSeconds, "BATTERY")) { recordingRepo.insertEvent(currentSessionId, tsSeconds, "BATTERY", record.percent) + if (currentMode == WhoopSessionMode.HISTORICAL_SYNC) historicalEventPersisted++ } } is WhoopRecord.Temperature -> if (settings.captureWhoopEvents) { val tsSeconds = record.timestamp.epochSecond - if (!shouldSkipHistoricalRecord(tsSeconds)) { + if (!shouldSkipExistingHistoricalEvent(tsSeconds, "TEMP")) { recordingRepo.insertEvent(currentSessionId, tsSeconds, "TEMP", record.celsius) + if (currentMode == WhoopSessionMode.HISTORICAL_SYNC) historicalEventPersisted++ } } is WhoopRecord.WristOn -> if (settings.captureWhoopEvents) { val tsSeconds = record.timestamp.epochSecond - if (!shouldSkipHistoricalRecord(tsSeconds)) { + if (!shouldSkipExistingHistoricalEvent(tsSeconds, "WRIST_ON")) { recordingRepo.insertEvent(currentSessionId, tsSeconds, "WRIST_ON", null) + if (currentMode == WhoopSessionMode.HISTORICAL_SYNC) historicalEventPersisted++ } } is WhoopRecord.WristOff -> if (settings.captureWhoopEvents) { val tsSeconds = record.timestamp.epochSecond - if (!shouldSkipHistoricalRecord(tsSeconds)) { + if (!shouldSkipExistingHistoricalEvent(tsSeconds, "WRIST_OFF")) { recordingRepo.insertEvent(currentSessionId, tsSeconds, "WRIST_OFF", null) + if (currentMode == WhoopSessionMode.HISTORICAL_SYNC) historicalEventPersisted++ } } is WhoopRecord.DoubleTap -> if (settings.captureWhoopEvents) { val tsSeconds = record.timestamp.epochSecond - if (!shouldSkipHistoricalRecord(tsSeconds)) { + if (!shouldSkipExistingHistoricalEvent(tsSeconds, "DOUBLE_TAP")) { recordingRepo.insertEvent(currentSessionId, tsSeconds, "DOUBLE_TAP", null) + if (currentMode == WhoopSessionMode.HISTORICAL_SYNC) historicalEventPersisted++ } } is WhoopRecord.CapTouchAutoThreshold -> if (settings.captureWhoopEvents) { val tsSeconds = record.timestamp.epochSecond - if (!shouldSkipHistoricalRecord(tsSeconds)) { + if (!shouldSkipExistingHistoricalEvent(tsSeconds, "CAPTOUCH_AUTOTHRESHOLD_ACTION")) { recordingRepo.insertEvent( currentSessionId, tsSeconds, "CAPTOUCH_AUTOTHRESHOLD_ACTION", null, ) + if (currentMode == WhoopSessionMode.HISTORICAL_SYNC) historicalEventPersisted++ } } is WhoopRecord.HapticsFired -> if (settings.captureWhoopEvents) { val tsSeconds = record.timestamp.epochSecond - if (!shouldSkipHistoricalRecord(tsSeconds)) { + if (!shouldSkipExistingHistoricalEvent(tsSeconds, "HAPTICS_FIRED")) { recordingRepo.insertEvent( currentSessionId, tsSeconds, "HAPTICS_FIRED", record.patternId?.toFloat(), ) + if (currentMode == WhoopSessionMode.HISTORICAL_SYNC) historicalEventPersisted++ } } is WhoopRecord.HapticsTerminated -> if (settings.captureWhoopEvents) { val tsSeconds = record.timestamp.epochSecond - if (!shouldSkipHistoricalRecord(tsSeconds)) { + if (!shouldSkipExistingHistoricalEvent(tsSeconds, "HAPTICS_TERMINATED")) { recordingRepo.insertEvent( currentSessionId, tsSeconds, "HAPTICS_TERMINATED", record.reasonCode?.toFloat(), ) + if (currentMode == WhoopSessionMode.HISTORICAL_SYNC) historicalEventPersisted++ } } } @@ -547,6 +578,7 @@ class WhoopRecordingService : Service() { ) { val settings = settingsRepository.current() if (!settings.enableWhoopUnknownTagPrompts) return + if (observation.category == UnknownObservationCategory.OBSERVED_UNDECODED_EVENT) return if (!result.isNewSignature) return if (result.entity.userAnnotation != null) return if (!canPostUnknownTagPrompt()) return @@ -639,10 +671,68 @@ class WhoopRecordingService : Service() { intervalSeconds: Int, ): Boolean = lastPersistedSecond == null || (tsSeconds - lastPersistedSecond) >= intervalSeconds - private fun shouldSkipHistoricalRecord(tsSeconds: Long): Boolean = + private suspend fun shouldSkipExistingHistoricalImu(tsSeconds: Long): Boolean = currentMode == WhoopSessionMode.HISTORICAL_SYNC && - syncCutoffSeconds != null && - tsSeconds <= syncCutoffSeconds!! + recordingRepo.hasImuAtTimestamp(tsSeconds).also { exists -> + if (exists) historicalSkippedByCutoff++ + } + + private suspend fun shouldSkipExistingHistoricalPpg(tsSeconds: Long): Boolean = + currentMode == WhoopSessionMode.HISTORICAL_SYNC && + recordingRepo.hasPpgAtTimestamp(tsSeconds).also { exists -> + if (exists) historicalSkippedByCutoff++ + } + + private suspend fun shouldSkipExistingHistoricalHr(tsSeconds: Long): Boolean = + currentMode == WhoopSessionMode.HISTORICAL_SYNC && + recordingRepo.hasHrAtTimestamp(tsSeconds).also { exists -> + if (exists) historicalSkippedByCutoff++ + } + + private suspend fun shouldSkipExistingHistoricalEvent(tsSeconds: Long, eventType: String): Boolean = + currentMode == WhoopSessionMode.HISTORICAL_SYNC && + recordingRepo.hasEventAtTimestamp(tsSeconds, eventType).also { exists -> + if (exists) historicalSkippedByCutoff++ + } + + private fun resetHistoricalCounters() { + historicalImuPersisted = 0 + historicalPpgPersisted = 0 + historicalHrPersisted = 0 + historicalEventPersisted = 0 + historicalSkippedByCutoff = 0 + } + + private fun logHistoricalSummary(sessionId: Long) { + Timber.i( + "WHOOP historical sync summary session=$sessionId cutoff=$syncCutoffSeconds " + + "imu=$historicalImuPersisted ppg=$historicalPpgPersisted " + + "hr=$historicalHrPersisted events=$historicalEventPersisted " + + "skippedExisting=$historicalSkippedByCutoff" + ) + } + + private fun closeCurrentSessionIfNeeded(reason: String) { + val sessionId = currentSessionId + if (sessionId < 0L) return + runBlocking { + recordCollectorJob?.cancel() + unknownCollectorJob?.cancel() + runCatching { gattManager.disableActiveStreams() } + .onFailure { Timber.w(it, "Failed to disable WHOOP streams during $reason") } + recordingRepo.endSession(sessionId) + val hasAnyData = recordingRepo.sessionHasAnyData(sessionId) + if (!hasAnyData) { + recordingRepo.deleteSession(sessionId) + Timber.i("Dropped empty WHOOP session $sessionId during $reason") + } else { + Timber.i("Closed WHOOP session $sessionId during $reason") + enqueueProcessingWorkers(sessionId) + } + } + currentSessionId = -1L + syncCutoffSeconds = null + } private suspend fun applyBatterySaverProfileIfRecording() { if (currentSessionId < 0L || gattManager.state.value == ConnectionState.DISCONNECTED) return diff --git a/app/src/main/kotlin/com/sploot/app/ui/debug/DebugScreen.kt b/app/src/main/kotlin/com/sploot/app/ui/debug/DebugScreen.kt index dd080fb..f0cd62d 100644 --- a/app/src/main/kotlin/com/sploot/app/ui/debug/DebugScreen.kt +++ b/app/src/main/kotlin/com/sploot/app/ui/debug/DebugScreen.kt @@ -118,6 +118,17 @@ fun DebugScreen( } } + Spacer(Modifier.height(10.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MetricLabel(label = "Live frames", value = "${state.realtimeFrameCount}") + MetricLabel(label = "History frames", value = "${state.historicalFrameCount}") + MetricLabel(label = "History batches", value = "${state.historicalBatchMarkers}") + MetricLabel(label = "History done", value = "${state.historicalCompleteMarkers}") + } + Spacer(Modifier.height(8.dp)) Text( text = "Double-tap action: ${state.configuredDoubleTapAction}", @@ -168,6 +179,12 @@ fun DebugScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { + OutlinedButton( + onClick = vm::startHistoricalBackfill, + modifier = Modifier.weight(1f), + ) { + Text("Backfill Now") + } FilledTonalButton( onClick = vm::toggleHaptics, modifier = Modifier.weight(1f), diff --git a/app/src/main/kotlin/com/sploot/app/ui/debug/DebugViewModel.kt b/app/src/main/kotlin/com/sploot/app/ui/debug/DebugViewModel.kt index ac2de92..8ce909f 100644 --- a/app/src/main/kotlin/com/sploot/app/ui/debug/DebugViewModel.kt +++ b/app/src/main/kotlin/com/sploot/app/ui/debug/DebugViewModel.kt @@ -43,6 +43,10 @@ data class DebugUiState( val packetCount: Int = 0, val outgoingPacketCount: Int = 0, val incomingPacketCount: Int = 0, + val realtimeFrameCount: Int = 0, + val historicalFrameCount: Int = 0, + val historicalBatchMarkers: Int = 0, + val historicalCompleteMarkers: Int = 0, val traceEvents: List = emptyList(), val unknownAuditRows: List = emptyList(), ) @@ -82,12 +86,16 @@ class DebugViewModel @Inject constructor( hrBpm = 0, ppgChannelA = emptyList(), hapticsActive = false, - packetCount = 0, - outgoingPacketCount = 0, - incomingPacketCount = 0, - traceEvents = emptyList(), - unknownAuditRows = emptyList(), - ) + packetCount = 0, + outgoingPacketCount = 0, + incomingPacketCount = 0, + realtimeFrameCount = 0, + historicalFrameCount = 0, + historicalBatchMarkers = 0, + historicalCompleteMarkers = 0, + traceEvents = emptyList(), + unknownAuditRows = emptyList(), + ) } } } @@ -110,6 +118,10 @@ class DebugViewModel @Inject constructor( state.copy( outgoingPacketCount = state.outgoingPacketCount + if (trace.direction == TraceDirection.OUTGOING) 1 else 0, incomingPacketCount = state.incomingPacketCount + if (trace.direction == TraceDirection.INCOMING) 1 else 0, + realtimeFrameCount = state.realtimeFrameCount + if (trace.summary.contains("raw-realtime")) 1 else 0, + historicalFrameCount = state.historicalFrameCount + if (trace.summary.contains("historical-data")) 1 else 0, + historicalBatchMarkers = state.historicalBatchMarkers + if (trace.summary.contains("Historical batch marker")) 1 else 0, + historicalCompleteMarkers = state.historicalCompleteMarkers + if (trace.summary.contains("Historical sync complete")) 1 else 0, traceEvents = (listOf(trace) + state.traceEvents).take(80), ) } @@ -204,6 +216,16 @@ class DebugViewModel @Inject constructor( context.startService(WhoopRecordingService.stopIntent(context)) } + fun startHistoricalBackfill() { + val settings = settingsRepository.current() + if (settings.preferredWhoopDeviceAddress == null) { + _uiState.update { it.copy(latestBandEvent = "Pick a WHOOP device before backfill") } + return + } + context.startForegroundService(WhoopRecordingService.startHistoricalSyncIntent(context)) + _uiState.update { it.copy(latestBandEvent = "Requested historical backfill") } + } + fun onScreenVisible() { val settings = settingsRepository.current() if (settings.preferredWhoopDeviceAddress == null) return 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 7f67150..2ca762b 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 @@ -921,21 +921,39 @@ private fun TodaySignalsSection( ) TrendCard( title = "Heart Rate Today", - subtitle = "15-minute average heart rate", + subtitle = "5-minute average heart rate", accent = Color(0xFFF86D74), points = state.todayHeartRateTrend, + yAxis = TrendYAxis( + min = 40f, + max = 200f, + ticks = listOf(200f, 160f, 120f, 80f, 40f), + suffix = "", + ), ) TrendCard( title = "Breaths Per Minute Today", subtitle = "Derived respiratory rate where available", accent = Color(0xFF7AC7FF), points = state.todayRespRateTrend, + yAxis = TrendYAxis( + min = 8f, + max = 30f, + ticks = listOf(30f, 24f, 18f, 12f, 8f), + suffix = "", + ), ) TrendCard( title = "Skin Temperature Today", subtitle = "Average of recorded temperature events", accent = Color(0xFFFFA24C), points = state.todayTempTrend, + yAxis = TrendYAxis( + min = 25f, + max = 40f, + ticks = listOf(40f, 35f, 30f, 25f), + suffix = "C", + ), ) } } @@ -1064,6 +1082,7 @@ private fun TrendCard( subtitle: String, accent: Color, points: List, + yAxis: TrendYAxis? = null, ) { Card( modifier = Modifier.fillMaxWidth(), @@ -1083,6 +1102,7 @@ private fun TrendCard( LineTrendChart( points = points, accent = accent, + yAxis = yAxis, modifier = Modifier .fillMaxWidth() .height(180.dp), @@ -1498,56 +1518,99 @@ private fun CircularGauge( private fun LineTrendChart( points: List, accent: Color, + yAxis: TrendYAxis? = null, modifier: Modifier = Modifier, ) { val validPoints = points.mapIndexedNotNull { index, point -> point.value?.let { index to it } } + val axisConfig = remember(points, yAxis) { + yAxis ?: validPoints + .takeIf { it.isNotEmpty() } + ?.let { values -> + val min = values.minOf { it.second } + val max = values.maxOf { it.second } + val range = (max - min).takeIf { it > 0f } ?: 1f + TrendYAxis( + min = min, + max = max + if (max == min) range else 0f, + ticks = listOf(max, min + range / 2f, min), + suffix = "", + ) + } + } + val axisLabelColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f) + val gridColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.10f) Column(modifier = modifier) { - Canvas( + Row( modifier = Modifier .fillMaxWidth() .weight(1f), ) { - val width = size.width - val height = size.height - val padding = 24.dp.toPx() - val usableWidth = (width - padding * 2).coerceAtLeast(1f) - val usableHeight = (height - padding * 2).coerceAtLeast(1f) - - repeat(4) { line -> - val y = padding + usableHeight * (line / 3f) - drawLine( - color = Color.White.copy(alpha = 0.08f), - start = Offset(padding, y), - end = Offset(width - padding, y), - strokeWidth = 1.dp.toPx(), - ) + if (axisConfig != null) { + Column( + modifier = Modifier + .width(42.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + axisConfig.ticks.forEach { tick -> + Text( + text = axisConfig.format(tick), + style = MaterialTheme.typography.labelSmall, + color = axisLabelColor, + textAlign = TextAlign.End, + modifier = Modifier + .fillMaxWidth(), + ) + } + } } - if (validPoints.size >= 2) { - val min = validPoints.minOf { it.second } - val max = validPoints.maxOf { it.second } - val range = (max - min).takeIf { it > 0f } ?: 1f + Canvas( + modifier = Modifier + .fillMaxSize(), + ) { + val width = size.width + val height = size.height + val padding = 18.dp.toPx() + val usableWidth = (width - padding * 2).coerceAtLeast(1f) + val usableHeight = (height - padding * 2).coerceAtLeast(1f) + val axisMin = axisConfig?.min ?: validPoints.minOfOrNull { it.second } ?: 0f + val axisMax = axisConfig?.max ?: validPoints.maxOfOrNull { it.second } ?: 1f + val range = (axisMax - axisMin).takeIf { it > 0f } ?: 1f - val path = Path() - validPoints.forEachIndexed { idx, (index, value) -> - val x = padding + usableWidth * (index / (points.lastIndex.coerceAtLeast(1)).toFloat()) - val y = height - padding - ((value - min) / range) * usableHeight - if (idx == 0) path.moveTo(x, y) else path.lineTo(x, y) + val tickValues = axisConfig?.ticks ?: listOf(axisMax, axisMin + range / 2f, axisMin) + tickValues.forEach { tick -> + val y = height - padding - ((tick - axisMin) / range).coerceIn(0f, 1f) * usableHeight + drawLine( + color = gridColor, + start = Offset(padding, y), + end = Offset(width - padding, y), + strokeWidth = 1.dp.toPx(), + ) } - drawPath( - path = path, - color = accent, - style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round), - ) + if (validPoints.size >= 2) { + val path = Path() + validPoints.forEachIndexed { idx, (index, value) -> + val x = padding + usableWidth * (index / (points.lastIndex.coerceAtLeast(1)).toFloat()) + val y = height - padding - ((value - axisMin) / range).coerceIn(0f, 1f) * usableHeight + if (idx == 0) path.moveTo(x, y) else path.lineTo(x, y) + } - validPoints.forEach { (index, value) -> - val x = padding + usableWidth * (index / (points.lastIndex.coerceAtLeast(1)).toFloat()) - val y = height - padding - ((value - min) / range) * usableHeight - drawCircle(color = accent, radius = 4.dp.toPx(), center = Offset(x, y)) + drawPath( + path = path, + color = accent, + style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round), + ) + + validPoints.forEach { (index, value) -> + val x = padding + usableWidth * (index / (points.lastIndex.coerceAtLeast(1)).toFloat()) + val y = height - padding - ((value - axisMin) / range).coerceIn(0f, 1f) * usableHeight + drawCircle(color = accent, radius = 4.dp.toPx(), center = Offset(x, y)) + } } } } @@ -1568,6 +1631,20 @@ private fun LineTrendChart( } } +private data class TrendYAxis( + val min: Float, + val max: Float, + val ticks: List, + val suffix: String, +) { + fun format(value: Float): String = + if (suffix.isBlank()) { + value.toInt().toString() + } else { + "${value.toInt()}$suffix" + } +} + private fun visibleAxisLabels(points: List, maxLabels: Int = 6): List { if (points.isEmpty()) return emptyList() if (points.size <= maxLabels) return points 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 94b0471..5849b1b 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 @@ -279,11 +279,20 @@ class DashboardViewModel @Inject constructor( .atStartOfDay(zone) .toEpochSecond() - val sessionsInRange = sleepRepo.getSessionsInRange(fromSeconds, nowSeconds) + val allSessionsInRange = sleepRepo.getSessionsInRange(fromSeconds, nowSeconds) + val sessionsInRange = allSessionsInRange .filter { it.source == SleepSource.ALGO } + .filter { it.isPlausibleSleepSession() } .sortedBy { it.startEpochSeconds } - val todaysSessions = sleepRepo.getSessionsInRange(startOfToday, nowSeconds) + val garminSessionsInRange = allSessionsInRange + .filter { it.source == SleepSource.GARMIN } + .filter { it.isPlausibleSleepSession() } + .sortedBy { it.startEpochSeconds } + val sleepDurationSessionsInRange = sessionsInRange.ifEmpty { garminSessionsInRange } + val allTodaysSessions = sleepRepo.getSessionsInRange(startOfToday, nowSeconds) + val todaysSessions = allTodaysSessions .filter { it.source == SleepSource.ALGO } + .filter { it.isPlausibleSleepSession() } .sortedBy { it.startEpochSeconds } val latestAlgoSleep = sessionsInRange.lastOrNull() val todaySleep = todaysSessions.lastOrNull() @@ -301,10 +310,10 @@ class DashboardViewModel @Inject constructor( } val averageSleepScore = sessionsInRange.mapNotNull { it.totalScore }.averageNumberOrNull() - val averageSleepHours = sessionsInRange + val averageSleepHours = sleepDurationSessionsInRange .map { (it.endEpochSeconds - it.startEpochSeconds).coerceAtLeast(0L) / 3600f } .averageNumberOrNull() - val averageSleepEfficiency = sessionsInRange.mapNotNull { it.efficiencyPercent }.averageNumberOrNull() + val averageSleepEfficiency = sleepDurationSessionsInRange.mapNotNull { it.efficiencyPercent }.averageNumberOrNull() val hrvWindows = sleepRepo.getHrvWindowsSince(fromSeconds) val activities = activityRepo.getActivitiesInRange(fromSeconds, nowSeconds) @@ -507,14 +516,14 @@ class DashboardViewModel @Inject constructor( private fun buildIntradayTrend( startOfToday: Long, nowSeconds: Long, - bucketSeconds: Long = 15 * 60L, + bucketSeconds: Long = 5 * 60L, valueForBucket: (Long, Long) -> Float?, ): List { val bucketCount = ((24 * 3600L) / bucketSeconds).toInt() return (0 until bucketCount).map { index -> val bucketStart = startOfToday + index * bucketSeconds val bucketEnd = bucketStart + bucketSeconds - val label = if (index % 8 == 0) { + val label = if (index % 12 == 0) { Instant.ofEpochSecond(bucketStart) .atZone(ZoneId.systemDefault()) .toLocalTime() @@ -530,6 +539,14 @@ class DashboardViewModel @Inject constructor( } } +private fun com.sploot.domain.model.SleepSession.isPlausibleSleepSession(): Boolean { + val durationHours = (endEpochSeconds - startEpochSeconds).coerceAtLeast(0L) / 3600f + return durationHours in MIN_PLAUSIBLE_SLEEP_HOURS..MAX_PLAUSIBLE_SLEEP_HOURS +} + +private const val MIN_PLAUSIBLE_SLEEP_HOURS = 2f +private const val MAX_PLAUSIBLE_SLEEP_HOURS = 14f + private fun estimateRedIrRatio( red: IntArray, infrared: IntArray, diff --git a/app/src/main/kotlin/com/sploot/app/worker/SleepProcessingWorker.kt b/app/src/main/kotlin/com/sploot/app/worker/SleepProcessingWorker.kt index a8ed590..0e0b939 100644 --- a/app/src/main/kotlin/com/sploot/app/worker/SleepProcessingWorker.kt +++ b/app/src/main/kotlin/com/sploot/app/worker/SleepProcessingWorker.kt @@ -8,6 +8,7 @@ import androidx.work.WorkerParameters import androidx.work.workDataOf import com.sploot.data.entity.RawImuEntity import com.sploot.data.entity.RawPpgEntity +import com.sploot.data.entity.HrSampleEntity import com.sploot.data.repository.AlgorithmRepository import com.sploot.data.repository.AlgorithmReviewRepository import com.sploot.data.repository.RecordingRepository @@ -58,34 +59,75 @@ class SleepProcessingWorker @AssistedInject constructor( private suspend fun processSession(sessionId: Long) { val activeRevision = algorithmRepository.getOrCreateActiveRevision(MetricFamily.SLEEP) - val ppgRows = recordingRepo.getRawPpgForSession(sessionId) - val imuRows = recordingRepo.getRawImuForSession(sessionId) + val rawPpgRows = recordingRepo.getRawPpgForSession(sessionId) + val rawImuRows = recordingRepo.getRawImuForSession(sessionId) + val rawHrRows = recordingRepo.getHrSamplesForSession(sessionId) - if (ppgRows.size < MIN_PPG_ROWS || imuRows.size < MIN_IMU_ROWS) { - Timber.i("Session $sessionId too short to process (${ppgRows.size} PPG rows, ${imuRows.size} IMU rows)") + val sleepWindow = selectContiguousSleepWindow( + timestamps = rawImuRows.map { it.tsSeconds }, + ) + if (sleepWindow == null) { + Timber.i("Session $sessionId skipped: no plausible contiguous IMU-backed sleep window") return } - val sessionStartSec = ppgRows.first().tsSeconds - 1L - val sessionEndSec = ppgRows.last().tsSeconds + val ppgRows = rawPpgRows.filter { it.tsSeconds in sleepWindow.startSeconds until sleepWindow.endSeconds } + val imuRows = rawImuRows.filter { it.tsSeconds in sleepWindow.startSeconds until sleepWindow.endSeconds } + val hrRows = rawHrRows.filter { it.tsSeconds in sleepWindow.startSeconds until sleepWindow.endSeconds } + + if (imuRows.size < MIN_IMU_ROWS || hrRows.size < MIN_HR_ROWS) { + Timber.i( + "Session $sessionId too sparse to process after IMU window filtering " + + "(${ppgRows.size} PPG rows, ${imuRows.size} IMU rows, ${hrRows.size} HR rows)" + ) + return + } - val ppgSamples = ppgRows.decodePpgChannelA() - val peakIndices = PpgPeakDetector.detect(ppgSamples, SAMPLE_RATE_HZ) - val ibiMs = IbiExtractor.extract(peakIndices, SAMPLE_RATE_HZ) + val sessionStartSec = sleepWindow.startSeconds + val sessionEndSec = sleepWindow.endSeconds + val windowSeconds = (sessionEndSec - sessionStartSec).coerceAtLeast(1L) + val imuCoverage = imuRows.map { it.tsSeconds }.distinct().size.toFloat() / windowSeconds + val hrCoverage = hrRows.map { it.tsSeconds }.distinct().size.toFloat() / windowSeconds + + if (imuCoverage < MIN_IMU_COVERAGE_RATIO || hrCoverage < MIN_HR_COVERAGE_RATIO) { + Timber.i( + "Session $sessionId skipped: insufficient signal coverage in sleep window " + + "(imu=${"%.2f".format(imuCoverage)}, hr=${"%.2f".format(hrCoverage)}, " + + "windowSeconds=$windowSeconds)" + ) + return + } - val hrvWindows = buildHrvWindows(sessionId, sessionStartSec, peakIndices, ibiMs) + val hrvWindows = if (ppgRows.size >= MIN_PPG_ROWS) { + val ppgSamples = ppgRows.decodePpgChannelA() + val peakIndices = PpgPeakDetector.detect(ppgSamples, SAMPLE_RATE_HZ) + val ibiMs = IbiExtractor.extract(peakIndices, SAMPLE_RATE_HZ) + buildHrvWindows(sessionId, sessionStartSec, peakIndices, ibiMs) + } else { + Timber.i( + "Session $sessionId has ${ppgRows.size} PPG rows; using HR/IMU-only sleep fallback" + ) + emptyList() + } + val numEpochs = ((sessionEndSec - sessionStartSec) / EPOCH_SEC).toInt() + if (numEpochs < MIN_SLEEP_EPOCHS) { + Timber.i("Session $sessionId too short to process as sleep (${numEpochs * EPOCH_SEC / 60} min)") + return + } val (accelX, accelY, accelZ) = imuRows.decodeAccel() - val numEpochs = minOf( - accelX.size / (SAMPLE_RATE_HZ * EPOCH_SEC), - ((sessionEndSec - sessionStartSec) / EPOCH_SEC).toInt(), + val activityCounts = computeActivityCounts( + accelX = accelX, + accelY = accelY, + accelZ = accelZ, + numEpochs = minOf(numEpochs, accelX.size / (SAMPLE_RATE_HZ * EPOCH_SEC)), ) - val activityCounts = computeActivityCounts(accelX, accelY, accelZ, numEpochs) val epochFeatures = buildEpochFeatures( sessionStartSec = sessionStartSec, numEpochs = numEpochs, hrvWindows = hrvWindows, imuRows = imuRows, + hrRows = hrRows, activityCounts = activityCounts, ) @@ -232,6 +274,7 @@ class SleepProcessingWorker @AssistedInject constructor( numEpochs: Int, hrvWindows: List, imuRows: List, + hrRows: List, activityCounts: FloatArray, ): List { return List(numEpochs) { epochIndex -> @@ -252,10 +295,11 @@ class SleepProcessingWorker @AssistedInject constructor( } val epochImuRows = imuRows.filter { it.tsSeconds in epochStart until epochEnd } - val meanHr = if (epochImuRows.isNotEmpty()) { - epochImuRows.map { it.hrBpm }.average().toFloat() - } else { - matchedWindow?.let { if (it.meanRrMs > 0f) 60000f / it.meanRrMs else null } ?: 60f + val epochHrRows = hrRows.filter { it.tsSeconds in epochStart until epochEnd } + val meanHr = when { + epochImuRows.isNotEmpty() -> epochImuRows.map { it.hrBpm }.average().toFloat() + epochHrRows.isNotEmpty() -> epochHrRows.map { it.hrBpm }.average().toFloat() + else -> matchedWindow?.let { if (it.meanRrMs > 0f) 60000f / it.meanRrMs else null } ?: 60f } SleepStageClassifier.EpochFeatures( @@ -289,6 +333,32 @@ class SleepProcessingWorker @AssistedInject constructor( return FloatArray(numEpochs) { epochIndex -> (rawCounts[epochIndex] / maxCount).toFloat() } } + private fun selectContiguousSleepWindow(timestamps: List): SleepCandidateWindow? { + val sorted = timestamps.distinct().sorted() + if (sorted.isEmpty()) return null + + val windows = mutableListOf() + var start = sorted.first() + var previous = start + var count = 1 + + for (timestamp in sorted.drop(1)) { + if (timestamp - previous > MAX_SAMPLE_GAP_SECONDS) { + windows += SleepCandidateWindow(start, previous + 1L, count) + start = timestamp + count = 1 + } else { + count += 1 + } + previous = timestamp + } + windows += SleepCandidateWindow(start, previous + 1L, count) + + return windows + .filter { it.durationSeconds in MIN_SLEEP_DURATION_SECONDS..MAX_SLEEP_DURATION_SECONDS } + .maxWithOrNull(compareBy { it.durationSeconds }.thenBy { it.sampleCount }) + } + private fun SleepStageClassifier.Stage.toDomainStage(): SleepStage = when (this) { SleepStageClassifier.Stage.DEEP -> SleepStage.DEEP SleepStageClassifier.Stage.LIGHT -> SleepStage.LIGHT @@ -317,10 +387,25 @@ class SleepProcessingWorker @AssistedInject constructor( private const val MIN_IBI_PER_WINDOW = 5 private const val MIN_PPG_ROWS = 60 private const val MIN_IMU_ROWS = 60 + private const val MIN_HR_ROWS = 60 + private const val MIN_SLEEP_EPOCHS = 40 + private const val MAX_SAMPLE_GAP_SECONDS = 2 * 60L + private const val MIN_IMU_COVERAGE_RATIO = 0.70f + private const val MIN_HR_COVERAGE_RATIO = 0.50f + private const val MIN_SLEEP_DURATION_SECONDS = 2 * 3600L + private const val MAX_SLEEP_DURATION_SECONDS = 14 * 3600L fun buildRequest(sessionId: Long) = OneTimeWorkRequestBuilder() .setInputData(workDataOf(KEY_SESSION_ID to sessionId)) .build() } + + private data class SleepCandidateWindow( + val startSeconds: Long, + val endSeconds: Long, + val sampleCount: Int, + ) { + val durationSeconds: Long = endSeconds - startSeconds + } } diff --git a/data/src/main/kotlin/com/sploot/data/dao/HrSampleDao.kt b/data/src/main/kotlin/com/sploot/data/dao/HrSampleDao.kt index 838a2d8..a22b54b 100644 --- a/data/src/main/kotlin/com/sploot/data/dao/HrSampleDao.kt +++ b/data/src/main/kotlin/com/sploot/data/dao/HrSampleDao.kt @@ -23,6 +23,9 @@ interface HrSampleDao { @Query("SELECT COUNT(*) FROM hr_samples WHERE sessionId = :sessionId") suspend fun countForSession(sessionId: Long): Int + @Query("SELECT COUNT(*) FROM hr_samples WHERE tsSeconds = :tsSeconds") + suspend fun countAtTimestamp(tsSeconds: Long): Int + @Query("SELECT MAX(tsSeconds) FROM hr_samples") suspend fun getLatestTimestamp(): Long? } 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 483c34e..2d02019 100644 --- a/data/src/main/kotlin/com/sploot/data/dao/RawImuDao.kt +++ b/data/src/main/kotlin/com/sploot/data/dao/RawImuDao.kt @@ -21,6 +21,9 @@ interface RawImuDao { @Query("SELECT COUNT(*) FROM raw_imu WHERE sessionId = :sessionId") suspend fun countForSession(sessionId: Long): Int + @Query("SELECT COUNT(*) FROM raw_imu WHERE tsSeconds = :tsSeconds") + suspend fun countAtTimestamp(tsSeconds: Long): Int + @Query("SELECT MAX(tsSeconds) FROM raw_imu") suspend fun getLatestTimestamp(): Long? } diff --git a/data/src/main/kotlin/com/sploot/data/dao/RawPpgDao.kt b/data/src/main/kotlin/com/sploot/data/dao/RawPpgDao.kt index 79ff11c..ac37476 100644 --- a/data/src/main/kotlin/com/sploot/data/dao/RawPpgDao.kt +++ b/data/src/main/kotlin/com/sploot/data/dao/RawPpgDao.kt @@ -20,6 +20,9 @@ interface RawPpgDao { @Query("SELECT COUNT(*) FROM raw_ppg WHERE sessionId = :sessionId") suspend fun countForSession(sessionId: Long): Int + @Query("SELECT COUNT(*) FROM raw_ppg WHERE tsSeconds = :tsSeconds") + suspend fun countAtTimestamp(tsSeconds: Long): Int + @Query("SELECT MAX(tsSeconds) FROM raw_ppg") suspend fun getLatestTimestamp(): Long? } diff --git a/data/src/main/kotlin/com/sploot/data/dao/WhoopEventDao.kt b/data/src/main/kotlin/com/sploot/data/dao/WhoopEventDao.kt index 53415c2..a71e39c 100644 --- a/data/src/main/kotlin/com/sploot/data/dao/WhoopEventDao.kt +++ b/data/src/main/kotlin/com/sploot/data/dao/WhoopEventDao.kt @@ -20,6 +20,9 @@ interface WhoopEventDao { @Query("SELECT COUNT(*) FROM whoop_events WHERE sessionId = :sessionId") suspend fun countForSession(sessionId: Long): Int + @Query("SELECT COUNT(*) FROM whoop_events WHERE tsSeconds = :tsSeconds AND eventType = :eventType") + suspend fun countAtTimestamp(tsSeconds: Long, eventType: String): Int + @Query("SELECT MAX(tsSeconds) FROM whoop_events") suspend fun getLatestTimestamp(): Long? } 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 21e430e..dfac00e 100644 --- a/data/src/main/kotlin/com/sploot/data/repository/RecordingRepository.kt +++ b/data/src/main/kotlin/com/sploot/data/repository/RecordingRepository.kt @@ -157,6 +157,21 @@ class RecordingRepository @Inject constructor( hrDao.insert(HrSampleEntity(sessionId, tsSeconds, hrBpm)) } + suspend fun hasImuAtTimestamp(tsSeconds: Long): Boolean = + imuDao.countAtTimestamp(tsSeconds) > 0 + + suspend fun hasPpgAtTimestamp(tsSeconds: Long): Boolean = + ppgDao.countAtTimestamp(tsSeconds) > 0 + + suspend fun hasHrAtTimestamp(tsSeconds: Long): Boolean = + hrDao.countAtTimestamp(tsSeconds) > 0 + + suspend fun hasEventAtTimestamp( + tsSeconds: Long, + eventType: String, + ): Boolean = + eventDao.countAtTimestamp(tsSeconds, eventType) > 0 + suspend fun getEventsByTypeSince( fromSeconds: Long, eventType: String, 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 d65ad16..333781a 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 @@ -18,6 +18,7 @@ import com.sploot.whoopble.protocol.EventDecoder import com.sploot.whoopble.protocol.FrameAssembler import com.sploot.whoopble.protocol.R10Decoder import com.sploot.whoopble.protocol.R11Decoder +import com.sploot.whoopble.protocol.R12Decoder import com.sploot.whoopble.protocol.R21Decoder import com.sploot.whoopble.protocol.RealtimeHrSummaryDecoder import com.sploot.whoopble.protocol.WhoopConstants @@ -117,6 +118,7 @@ class WhoopGattManager @Inject constructor( private var loggedR11FallbackIgnore = false private var loggedObservedR11Companion = false private var loggedObservedFirmwareLogs = false + private val recentProtocolContext = ArrayDeque() // ── GATT callback ───────────────────────────────────────────────────────── @@ -226,6 +228,7 @@ class WhoopGattManager @Inject constructor( this.sessionMode = sessionMode pendingLiveStreamConfig = streamConfig realtimeEnableIssued = false + recentProtocolContext.clear() connectionDeferred = CompletableDeferred() mtuDeferred = CompletableDeferred() @@ -301,6 +304,7 @@ class WhoopGattManager @Inject constructor( loggedR11FallbackIgnore = false loggedObservedR11Companion = false loggedObservedFirmwareLogs = false + recentProtocolContext.clear() _state.value = ConnectionState.DISCONNECTED dataAssembler.reset() eventAssembler.reset() @@ -437,6 +441,7 @@ class WhoopGattManager @Inject constructor( } decoded } + WhoopConstants.RECORD_TYPE_R12 -> R12Decoder.decode(frame) WhoopConstants.RECORD_TYPE_PPG -> R21Decoder.decode(frame) else -> null } @@ -446,8 +451,25 @@ class WhoopGattManager @Inject constructor( } record?.let { _records.tryEmit(it) } - if (record == null && packetType == 0x30) { - auditUnknownEvent(frame) + 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) + if (observedUndecodedEventTypeName(eventType) != null) { + auditObservedUndecodedEvent(frame) + } else if (decodedEventTypeName(eventType) == null) { + auditUnknownEvent(frame) + } + } + appendProtocolContext(eventContextSummary(frame)) } else if (record == null && (packetType == 0x28 || packetType == 0x2B || packetType == 0x2F)) { val recordType = frame.getOrNull(5)?.toInt()?.and(0xFF) if (recordType == WhoopConstants.RECORD_TYPE_COMPANION_IMU) { @@ -493,20 +515,26 @@ class WhoopGattManager @Inject constructor( frame[3] == 0xAB.toByte() && frame[4] == 0x31.toByte() ) { - val batchId = frame.copyOfRange(17, 25) - traceInternal("metadata", "Historical batch marker received") - serviceScopeLaunchBatchAck(batchId) + val batchNumber = frame.copyOfRange(17, 21) + traceInternal( + "metadata", + "Historical batch marker received batch=${frame.getUInt32LE(17)}", + ) + appendProtocolContext("metadata:historical-batch batch=${frame.getUInt32LE(17)}") + serviceScopeLaunchBatchAck(batchNumber) return true } 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 Timber.i("Whoop historical sync complete") } else if (sessionMode == WhoopSessionMode.LIVE_RECORDING && !realtimeEnableIssued) { traceInternal("metadata", "Live end-of-sync marker received, enabling realtime streams") + appendProtocolContext("metadata:live-sync-boundary size=${frame.size}") realtimeEnableIssued = true managerScope.launch { runCatching { @@ -532,13 +560,13 @@ class WhoopGattManager @Inject constructor( return false } - private fun serviceScopeLaunchBatchAck(batchId: ByteArray) { + private fun serviceScopeLaunchBatchAck(batchNumber: ByteArray) { managerScope.launch { runCatching { val localGatt = gatt ?: return@launch val cmdChar = localGatt.findCharacteristic(WhoopConstants.CMD_TO_STRAP_PREFIX) ?: return@launch - writeGatt(cmdChar, buildBatchAckPacket(batchCounter++, batchId)) + writeGatt(cmdChar, buildBatchAckPacket(batchCounter++, batchNumber)) }.onFailure { Timber.w(it, "Failed to ACK historical batch marker") } @@ -575,6 +603,7 @@ class WhoopGattManager @Inject constructor( summary = "Write ${commandSummary(value)}", bytes = value, ) + appendProtocolContext("out:${commandSummary(value)}") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { gatt!!.writeCharacteristic( @@ -691,6 +720,20 @@ class WhoopGattManager @Inject constructor( ) } + private fun appendProtocolContext(summary: String) { + while (recentProtocolContext.size >= RECENT_PROTOCOL_CONTEXT_LIMIT) { + recentProtocolContext.removeFirst() + } + recentProtocolContext.addLast(summary.take(MAX_PROTOCOL_CONTEXT_ENTRY_CHARS)) + } + + private fun precedingProtocolContextNote(): String = + if (recentProtocolContext.isEmpty()) { + "Preceding context: none" + } else { + "Preceding context: ${recentProtocolContext.joinToString(separator = " | ")}" + } + private fun emitTrace(event: WhoopBleTraceEvent) { _traceEvents.tryEmit(event) Timber.tag("WhoopTrace").d( @@ -711,6 +754,7 @@ class WhoopGattManager @Inject constructor( channel = "cmd", summary = "CMD_RESPONSE ${commandName(cmd)} payload=${payload.hexPreview(12)}", ) + appendProtocolContext("cmd-response:${commandName(cmd)} payload=${payload.hexPreview(12)}") } private fun decodeCommandResponseRecord(frame: ByteArray): WhoopRecord? { @@ -766,20 +810,12 @@ class WhoopGattManager @Inject constructor( private fun traceEvent(frame: ByteArray) { if (frame.size < 8 || frame.getOrNull(4)?.toInt()?.and(0xFF) != 0x30) return val eventType = eventType(frame) ?: return - val summary = when (eventType) { - WhoopConstants.EVENT_DOUBLE_TAP -> "EVENT DOUBLE_TAP" - WhoopConstants.EVENT_BLE_REALTIME_HR_ON -> "EVENT BLE_REALTIME_HR_ON" - WhoopConstants.EVENT_BLE_REALTIME_HR_OFF -> "EVENT BLE_REALTIME_HR_OFF" - WhoopConstants.EVENT_SET_RTC -> "EVENT SET_RTC" - WhoopConstants.EVENT_CAPTOUCH_AUTOTHRESHOLD_ACTION -> "EVENT CAPTOUCH_AUTOTHRESHOLD_ACTION" - WhoopConstants.EVENT_BLE_SYSTEM_INITIALIZED -> "EVENT BLE_SYSTEM_INITIALIZED" - WhoopConstants.EVENT_HAPTICS_FIRED -> "EVENT HAPTICS_FIRED" - WhoopConstants.EVENT_HAPTICS_TERMINATED -> "EVENT HAPTICS_TERMINATED" - else -> { + val summary = decodedEventTypeName(eventType)?.let { "EVENT $it" } + ?: observedUndecodedEventTypeName(eventType)?.let { "EVENT $it (observed undecoded)" } + ?: run { traceInternal(channel = "event", summary = "Unknown WHOOP event type=$eventType size=${frame.size}") return } - } traceInternal(channel = "event", summary = summary) } @@ -793,7 +829,24 @@ class WhoopGattManager @Inject constructor( identifierLabel = eventType?.let { "eventType=$it" } ?: "eventType=unknown", frameSizeBytes = frame.size, hexPreview = frame.hexPreview(40), - note = "Unknown WHOOP event emitted by strap", + note = "Unknown WHOOP event emitted by strap. ${precedingProtocolContextNote()}", + ) + _unknownObservations.tryEmit(observation) + } + + private fun auditObservedUndecodedEvent(frame: ByteArray) { + val eventType = eventType(frame) + val observation = WhoopUnknownObservation( + category = UnknownObservationCategory.OBSERVED_UNDECODED_EVENT, + packetType = frame.getOrNull(4)?.toInt()?.and(0xFF), + packetTypeName = packetTypeName(frame), + identifier = eventType, + identifierLabel = eventType?.let { + "eventType=$it (${observedUndecodedEventTypeName(it)})" + } ?: "eventType=unknown", + frameSizeBytes = frame.size, + hexPreview = frame.hexPreview(40), + note = "Observed recurring WHOOP event with undecoded semantics. ${precedingProtocolContextNote()}", ) _unknownObservations.tryEmit(observation) } @@ -808,7 +861,7 @@ class WhoopGattManager @Inject constructor( identifierLabel = recordTypeLabel(frame), frameSizeBytes = frame.size, hexPreview = frame.hexPreview(40), - note = "Undecoded WHOOP data frame", + note = "Undecoded WHOOP data frame. ${precedingProtocolContextNote()}", ) _unknownObservations.tryEmit(observation) } @@ -906,6 +959,38 @@ class WhoopGattManager @Inject constructor( recordType == WhoopConstants.RECORD_TYPE_R20 || recordType == WhoopConstants.RECORD_TYPE_R24 + private fun decodedEventTypeName(eventType: Int?): String? = + when (eventType) { + WhoopConstants.EVENT_BATTERY -> "BATTERY" + WhoopConstants.EVENT_WRIST_ON -> "WRIST_ON" + WhoopConstants.EVENT_WRIST_OFF -> "WRIST_OFF" + WhoopConstants.EVENT_DOUBLE_TAP -> "DOUBLE_TAP" + WhoopConstants.EVENT_SET_RTC -> "SET_RTC" + WhoopConstants.EVENT_TEMP -> "TEMP" + WhoopConstants.EVENT_CAPTOUCH_AUTOTHRESHOLD_ACTION -> "CAPTOUCH_AUTOTHRESHOLD_ACTION" + WhoopConstants.EVENT_BLE_REALTIME_HR_ON -> "BLE_REALTIME_HR_ON" + WhoopConstants.EVENT_BLE_REALTIME_HR_OFF -> "BLE_REALTIME_HR_OFF" + WhoopConstants.EVENT_BLE_SYSTEM_INITIALIZED -> "BLE_SYSTEM_INITIALIZED" + WhoopConstants.EVENT_HAPTICS_FIRED -> "HAPTICS_FIRED" + WhoopConstants.EVENT_EXTENDED_BATTERY_INFORMATION -> "EXTENDED_BATTERY_INFORMATION" + WhoopConstants.EVENT_HAPTICS_TERMINATED -> "HAPTICS_TERMINATED" + else -> null + } + + private fun observedUndecodedEventTypeName(eventType: Int?): String? = + when (eventType) { + WhoopConstants.EVENT_UNDECODED_1 -> "UNDECODED_1" + WhoopConstants.EVENT_UNDECODED_7 -> "UNDECODED_7" + WhoopConstants.EVENT_UNDECODED_11 -> "UNDECODED_11" + WhoopConstants.EVENT_UNDECODED_12 -> "UNDECODED_12" + WhoopConstants.EVENT_UNDECODED_21 -> "UNDECODED_21" + WhoopConstants.EVENT_UNDECODED_24 -> "UNDECODED_24" + WhoopConstants.EVENT_UNDECODED_29 -> "UNDECODED_29" + WhoopConstants.EVENT_UNDECODED_36 -> "UNDECODED_36" + WhoopConstants.EVENT_UNDECODED_44 -> "UNDECODED_44" + else -> null + } + private fun eventType(frame: ByteArray): Int? = if (frame.size >= 8 && frame.getOrNull(4)?.toInt()?.and(0xFF) == 0x30) { ((frame[7].toInt() and 0xFF) shl 8) or (frame[6].toInt() and 0xFF) @@ -913,6 +998,21 @@ class WhoopGattManager @Inject constructor( null } + private fun eventContextSummary(frame: ByteArray): String { + val type = eventType(frame) + val name = decodedEventTypeName(type) ?: observedUndecodedEventTypeName(type) ?: "UNKNOWN" + val tsSeconds = if (frame.size >= 12) frame.getUInt32LE(8) else null + val subsecond = if (frame.size >= 14) { + (frame[12].toInt() and 0xFF) or ((frame[13].toInt() and 0xFF) shl 8) + } else { + null + } + val payloadOffset = 4 + WhoopConstants.EventHeader.PAYLOAD_START + val payloadEnd = frame.size - 4 + val payload = if (payloadEnd > payloadOffset) frame.copyOfRange(payloadOffset, payloadEnd) else byteArrayOf() + return "event:type=$type/$name ts=$tsSeconds sub=$subsecond size=${frame.size} payload=${payload.hexPreview(12)}" + } + private fun ByteArray.hexPreview(maxBytes: Int = 24): String = take(maxBytes).joinToString(separator = " ") { byte -> "%02x".format(byte.toInt() and 0xFF) } @@ -934,13 +1034,13 @@ class WhoopGattManager @Inject constructor( private fun Int.toHexByte(): String = "%02x".format(this and 0xFF) - private fun buildBatchAckPacket(counter: Int, batchId: ByteArray): ByteArray { + private fun buildBatchAckPacket(counter: Int, batchNumber: ByteArray): ByteArray { val inner = byteArrayOf( WhoopConstants.CMD_TYPE_COMMAND.toByte(), counter.toByte(), 0x17, 0x01, - ) + batchId + ) + batchNumber.copyOf(4) + byteArrayOf(0x00, 0x00, 0x00, 0x00) val frameLength = inner.size + 4 val lenLo = (frameLength and 0xFF).toByte() @@ -997,5 +1097,7 @@ class WhoopGattManager @Inject constructor( companion object { private const val DEFAULT_HARVARD_HAPTIC_PATTERN_ID = 2 + private const val RECENT_PROTOCOL_CONTEXT_LIMIT = 8 + private const val MAX_PROTOCOL_CONTEXT_ENTRY_CHARS = 180 } } diff --git a/whoop-ble/src/main/kotlin/com/sploot/whoopble/model/WhoopUnknownObservation.kt b/whoop-ble/src/main/kotlin/com/sploot/whoopble/model/WhoopUnknownObservation.kt index 9e63778..f3122b7 100644 --- a/whoop-ble/src/main/kotlin/com/sploot/whoopble/model/WhoopUnknownObservation.kt +++ b/whoop-ble/src/main/kotlin/com/sploot/whoopble/model/WhoopUnknownObservation.kt @@ -16,5 +16,6 @@ data class WhoopUnknownObservation( enum class UnknownObservationCategory { EVENT, + OBSERVED_UNDECODED_EVENT, DATA_FRAME, } diff --git a/whoop-ble/src/main/kotlin/com/sploot/whoopble/protocol/R12Decoder.kt b/whoop-ble/src/main/kotlin/com/sploot/whoopble/protocol/R12Decoder.kt new file mode 100644 index 0000000..93951c5 --- /dev/null +++ b/whoop-ble/src/main/kotlin/com/sploot/whoopble/protocol/R12Decoder.kt @@ -0,0 +1,38 @@ +package com.sploot.whoopble.protocol + +import com.sploot.whoopble.model.WhoopRecord +import java.time.Instant + +/** + * Decodes compact historical R12 frames observed during SEND_HISTORICAL_DATA. + * + * These 96-byte historical-data frames carry the common data timestamp header + * and a plausible HR byte at the same relative offset used by R10. + */ +object R12Decoder { + + private const val MIN_FRAME_SIZE = 4 + WhoopConstants.R10.OFFSET_HR + 1 + private const val MIN_PLAUSIBLE_HR_BPM = 30 + private const val MAX_PLAUSIBLE_HR_BPM = 240 + + fun decode(frame: ByteArray): WhoopRecord.HeartRate? { + if (frame.size < MIN_FRAME_SIZE) return null + + val base = 4 + val tsSeconds = frame.getUInt32LE(base + WhoopConstants.DataHeader.OFFSET_TS_SECONDS) + val hrBpm = frame[base + WhoopConstants.R10.OFFSET_HR].toInt() and 0xFF + if (hrBpm !in MIN_PLAUSIBLE_HR_BPM..MAX_PLAUSIBLE_HR_BPM) return null + + return WhoopRecord.HeartRate( + timestamp = Instant.ofEpochSecond(tsSeconds), + hrBpm = hrBpm, + source = "R12", + ) + } + + private fun ByteArray.getUInt32LE(offset: Int): Long = + (this[offset].toLong() and 0xFF) or + ((this[offset + 1].toLong() and 0xFF) shl 8) or + ((this[offset + 2].toLong() and 0xFF) shl 16) or + ((this[offset + 3].toLong() and 0xFF) shl 24) +} 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 4218c60..7ffb685 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 @@ -59,15 +59,24 @@ object WhoopConstants { // ── Event type values (uint16 at inner content offset 2 in EVENT frames) ─ + const val EVENT_UNDECODED_1: Int = 1 const val EVENT_BATTERY: Int = 3 + const val EVENT_UNDECODED_7: Int = 7 const val EVENT_WRIST_ON: Int = 9 const val EVENT_WRIST_OFF: Int = 10 + const val EVENT_UNDECODED_11: Int = 11 + const val EVENT_UNDECODED_12: Int = 12 const val EVENT_DOUBLE_TAP: Int = 14 const val EVENT_SET_RTC: Int = 16 const val EVENT_TEMP: Int = 17 + const val EVENT_UNDECODED_21: Int = 21 + const val EVENT_UNDECODED_24: Int = 24 + const val EVENT_UNDECODED_29: Int = 29 const val EVENT_CAPTOUCH_AUTOTHRESHOLD_ACTION: Int = 32 const val EVENT_BLE_REALTIME_HR_ON: Int = 33 const val EVENT_BLE_REALTIME_HR_OFF: Int = 34 + const val EVENT_UNDECODED_36: Int = 36 + const val EVENT_UNDECODED_44: Int = 44 const val EVENT_BLE_SYSTEM_INITIALIZED: Int = 45 const val EVENT_HAPTICS_FIRED: Int = 60 const val EVENT_EXTENDED_BATTERY_INFORMATION: Int = 63