diff --git a/CLAUDE.md b/CLAUDE.md index 60503c5e0..24b66d6d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,6 +117,7 @@ The schema version is derived from the migration files (highest `N.sqm` + 1). Do 5. Echo achieved load (#1182): an Echo set with no measured load pushes the non-null `weightKg = 0` sentinel with `estimatedOneRepMaxKg = null`. Before this ships, deploy the portal guard: `buildExerciseProgressRows` must not write a 0 `max_weight_kg`/`estimated_1rm_kg`/`total_volume_kg` progress row for such an exercise, and session detail must render 0-weight Echo sets as "Load unavailable", not "0 kg". 6. Install a minified release APK (R8 keep rules were narrowed in #960; PR CI builds debug only) and verify on device: BLE connect and a workout, sync push/pull, backup export/import, settings persistence across restart, CSV import/export, and a Health Connect write. 7. Custom equipment (#1227): deploy the portal's `CUSTOM_EQUIPMENT` profile-preference section first (additive migration, `profilePreferenceContract.ts`, `mobile-sync-pull` and manifest emission; portal branch `enhancement/1227-custom-equipment`). Until it is deployed the portal rejects the section, so each device keeps its list local and dirty. Devices on 1.0.6 or older treat `U_` custom exercises as bodyweight if they edit them, so the release notes must tell users to update every device before editing those exercises. +8. Polar H10 (#1326): complete [the Polar device release checks](docs/qa/polar-h10-release.md) on Android and iOS with both trainers, including concurrent trainer reconnect and Polar discovery on iOS. Shared CoreBluetooth scan interference remains unverified until that device check passes. Release notes must state that Polar summaries and raw traces are local plus file backup only; portal sync does not restore them. ## Sync Architecture diff --git a/docs/qa/persisted-file-upgrade.md b/docs/qa/persisted-file-upgrade.md index 72e45d752..842056825 100644 --- a/docs/qa/persisted-file-upgrade.md +++ b/docs/qa/persisted-file-upgrade.md @@ -2,7 +2,7 @@ This runbook verifies PR 709's physical filename migration without treating a synthetic fixture as production evidence. The automated harness is destructive only to one explicitly selected disposable emulator and one exact package name; it never clears data during an upgrade. -The harness default `-ExpectedSchemaVersion` is 59, the current database `user_version` (highest migration file + 1). Pass that parameter only to override it. +The harness default `-ExpectedSchemaVersion` is 60, the current database `user_version` (highest migration file + 1). Pass that parameter only to override it. ## Automated v0.9.6 debug-lineage matrix diff --git a/docs/qa/polar-h10-release.md b/docs/qa/polar-h10-release.md new file mode 100644 index 000000000..ec9cdab98 --- /dev/null +++ b/docs/qa/polar-h10-release.md @@ -0,0 +1,25 @@ +# Polar H10 release notes and device checks + +## User-facing release notes + +- Polar H10 adds heart rate, R-R intervals, ECG preview, torso movement and optional five-minute post-workout recovery tracking. +- Workout averages and movement maxima describe the recorded set. Recovery measurements are stored separately on its session, only after the 60-second or 300-second observation window completes. Canceling early leaves unfinished measurements unavailable. +- These measurements describe a workout and are not a clinical risk assessment. +- Polar summaries and traces stay on this device and in exported file backups. They are not synchronized to Phoenix Portal. Reinstalling and pulling from the portal does not restore Polar data; restore a file backup to recover it. +- Raw ECG storage is off by default. Enable **Save raw ECG** in Polar settings to retain traces. Capture is limited to five minutes (39,000 samples) per recording, stored traces expire after 30 days, and only the newest 36,000 segments are retained. Older traces can disappear before their sessions; summaries remain available. Retention is applied when the telemetry repository opens and when it saves telemetry. +- File backups preserve summaries regardless of the raw telemetry option. Beat samples, ECG and synchronized metric samples require **Include raw telemetry** when exporting. Backup files are not changed by local trace expiry. +- Streaming and automatic reconnection start with recording. Opening the workout screen alone does not start a scan or ECG/accelerometer stream. An explicit Disconnect remains effective until the user reconnects. + +## Required device evidence before release + +Local automated tests do not satisfy these checks. Record app commit, OS/device, strap firmware, trainer model and observed results for each run. + +1. On both Android and iOS, pair an H10 alongside a V-Form and a Trainer+. Verify the first set, multiple sets, manual stop, automatic completion and routine completion. Check that the completion UI remains visible if the trainer is switched off while recovery continues. +2. On iOS, run a trainer reconnect scan concurrently with a remembered-H10 targeted scan and a manual H10 scan. Let each scan succeed, time out and be canceled in both orders. Confirm ending either scanner does not prevent the other device reconnecting. Kable shares a CoreBluetooth central manager, so this interaction remains an explicit release gate. +3. Take the H10 out of range during connection and PMD setup. Reconnect without restarting the app. Repeat with a failed PMD subscription; standard heart-rate notifications and connection-state changes must remain observable. +4. Leave the workout screen for routine completion during recovery. Check ECG/ACC continuity while recording owns the streams, and teardown after recovery ends. Verify Disconnect in Settings prevents automatic reconnection on subsequent screen entry or set start until explicitly reconnected. +5. Cancel recovery at 20 seconds and after 60 seconds. Confirm the first stores no HRR window and the second preserves HRR60 only. Remove the strap briefly and for more than three seconds; incomplete windows must remain absent, while previously captured rows survive. Repeat with HR notifications interrupted while PMD traffic continues. +6. Verify raw ECG default-off, opt-in, five-minute capture cap, expiry and bounded storage. Export and restore with raw telemetry both enabled and disabled, and verify historical recovery values with no strap connected. +7. On physical iOS, verify telemetry transactions with the production SQLite driver while a new set starts and an earlier set finishes saving. Reopen the app and confirm parent ownership, summaries and retained raw rows. + +The release remains gated on macOS/iOS compilation, hosted CI for the published patch, and these physical-device checks. diff --git a/docs/qa/polar-h10-review-1326.md b/docs/qa/polar-h10-review-1326.md new file mode 100644 index 000000000..a7edfbf00 --- /dev/null +++ b/docs/qa/polar-h10-review-1326.md @@ -0,0 +1,56 @@ +# PR 1326 review resolution + +Reviewed source: `afbbcbace207b977c4f0f523b40c7bc270ac0a3c` from `StuGotz/Project-Phoenix-MP`, branch `feature/polar-h10-telemetry`. + +This records local remediation of the October 29 owner review and remaining earlier feedback. It is not a hosted CI or device-test receipt. + +| Finding | Resolution | +| --- | --- | +| 1. Connection timeout | Timeout reaches cleanup and an error state; external cancellation still propagates. | +| 2. PMD subscription failure | Readiness deferreds complete exceptionally; PMD setup failure does not kill the connection observer. | +| 3. Recovery contaminates set summary | Freeze the set at snapshot/manual stop; recovery writes only HRR columns. | +| 4. Early cancellation mislabels HRR | Persist only elapsed windows, using fresh HR observations. | +| 5. Main-thread database flush | Dedicated telemetry repository runs transactional saves on its IO dispatcher; callers await the queued work without blocking the UI thread. | +| 6. Queries for non-Polar sessions | Check summary columns before querying beat, ECG or metric rows. | +| 7. Interrupted recovery loses rows | Detach and queue captured rows before cancellation or the next recording; debounce contact loss. | +| 8. Screen disposal stops recovery streams | Recording/cooldown owns streams; releasing a preview cannot stop an active owner. | +| 9. History hides recovery | Stored HRR renders independently of cooldown controls; grouped history selects and labels a recovery-bearing or latest sensor set. | +| 10. Disconnected trainer yields blank completion | Completion remains visible when the trainer disconnects. | +| 11. iOS concurrent scanner interference | Explicit physical-device release gate in `polar-h10-release.md` and `CLAUDE.md`; unverified on hardware. | +| 12. Protocol and storage policy | Install command response listener before write; fail missing/rejected ACKs; require an H10 name; parse all compressed ACC blocks; raw ECG defaults off with capture, age and storage caps. | +| 13. Manual-stop summary missing | Capture the recording's Polar summary at Stop and remap its raw rows to the saved session ID. | +| 14. Backup ID conflicts | Identical rows count as skipped; differing or cross-session ID collisions report conflicts without replacing local rows. | +| 15. Misleading backup test | Name and assertions explicitly distinguish retained summaries from excluded raw rows. | +| 16. Explicit disconnect overridden | Automatic reconnection respects the disconnect flag; screen entry no longer initiates reconnection. | +| 17. Shared-state races | Lock advertisement access and recording state; capture nullable heart-rate values before use. | +| 18. Global state on unrelated cards | Live connection and cooldown state require the matching session; read-only history cannot expose unrelated live state. | +| 19. Clinical labels | Replace risk classifications with observed values and neutral wording. | + +Earlier feedback also addressed: remove dead coordinator-ID fallbacks and duplicated cooldown guards; move storage ownership and row mapping into repositories/domain models; remove shipped testing stream toggles, screen-entry stream startup, stale migration-58 comments and unused imports; correct five schema-60 QA markers. Polar data remains local plus file backup only, documented in the release notes. + +Additional review coverage checks delayed commits across recordings, frozen summaries, transient contact loss with the watchdog running, HR-only signal loss while PMD continues, missing-parent batches after session deletion, and raw-ECG retention boundaries. + +## Validation + +- Initial Android unit run: 26 tests, zero failures, errors or skips. This includes the persisted-file harness contract that failed on the PR head. +- Initial Android debug assembly completed. +- Initial shared test compilation failed on two invalid Compose assertion imports; those imports were removed. +- Initial lint failed inside its IntelliJ PSI analysis of the new Compose test while source edits were in progress. The frozen-source retry passed. +- Shared full run: 5,441 cases, 5,429 passing, three failing and nine skipped. The failures were outdated expectations: a resumed set must own a new session ID, and disconnected completion content now shares the connected-content branch. After correcting those tests, the exact three failed cases passed their targeted rerun; successful tests were not repeated. Combined result: 5,432 passed, nine skipped, no outstanding failures. +- `:androidApp:lintDebug` passed; after the two test files changed, the affected `:shared:lintAnalyzeAndroidHostTest` also passed. +- Updated `:androidApp:assembleDebug` passed. The local build uses `-Pskip.supabase.check=true` and does not establish a live sync/provider gate. +- Schema manifest validation passed: 681 columns across 60 tables. The full shared run includes schema parity, Polar manager/parser/persistence/repository tests, backup completeness, manual-stop regression and Compose history regressions. +- `git diff --check` passed before the final test-expectation-only corrections. +- Physical Android/iOS BLE, native iOS SQLite, macOS/iOS compilation and hosted CI remain external release gates. + +Commands used (PowerShell quotes are required around the Gradle project property): + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest :androidApp:testDebugUnitTest :androidApp:lintDebug :androidApp:assembleDebug --continue +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest :androidApp:lintDebug :androidApp:assembleDebug --continue +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests '*PolarH10BleManagerTest.routine intermediate sets update phase to REST while final completion triggers cooldown' --tests '*Issue687WorkoutStartGateUiTest.recovery actions remain available while disconnected' --tests '*Issue687WorkoutStartGateUiTest.recovery actions remain available while reconnecting' :shared:lintAnalyzeAndroidHostTest --continue +``` + +The first attempt with an unquoted property stopped during configuration before compiling or testing. Logs are in the local temporary directory: `phoenix-pr1326-validation.log`, `phoenix-pr1326-validation-final.log`, and `phoenix-pr1326-targeted-recheck.log`. + +Publication details and hosted checks are recorded on the PR. This report does not establish review approval, merge or deployment. diff --git a/docs/qa/profile-schema42-fixture.md b/docs/qa/profile-schema42-fixture.md index e71695661..ecac82adb 100644 --- a/docs/qa/profile-schema42-fixture.md +++ b/docs/qa/profile-schema42-fixture.md @@ -1,6 +1,6 @@ # Profile schema-42 Android fixture -This recipe materializes the pre-profile database and legacy preference input used to prove the schema-42 to current-schema upgrade. The fixture source is commit `ac84d9bb8e156002833ad526bf324a8f12710da0`; do not substitute a moving branch or shortened SHA. The upgrade candidate is a debug APK from the current checkout. After that install, `PRAGMA user_version` must be 59 (highest migration file + 1). +This recipe materializes the pre-profile database and legacy preference input used to prove the schema-42 to current-schema upgrade. The fixture source is commit `ac84d9bb8e156002833ad526bf324a8f12710da0`; do not substitute a moving branch or shortened SHA. The upgrade candidate is a debug APK from the current checkout. After that install, `PRAGMA user_version` must be 60 (highest migration file + 1). All commands below are PowerShell commands. Start at the repository root. They use the installed Android SDK (`ANDROID_HOME`, else `ANDROID_SDK_ROOT`, else `%LOCALAPPDATA%\Android\Sdk`) and must not contact Supabase. The only network use is the explicit `git fetch` of the pinned fixture commit and Gradle dependency resolution for that commit. Do not pass `--offline`. @@ -171,9 +171,9 @@ foreach ($suffix in @('-wal', '-shm')) { if ($LASTEXITCODE -eq 0) { Receive-SandboxBytes $relative "$inspectDb$suffix" -AllowEmpty } } $userVersion = (& $hostSqlite -readonly -batch $inspectDb 'PRAGMA user_version;').Trim() -if ($userVersion -ne '59') { throw "Expected user_version 59 after upgrade, got $userVersion" } +if ($userVersion -ne '60') { throw "Expected user_version 60 after upgrade, got $userVersion" } & $hostSqlite -readonly -batch $inspectDb 'SELECT profile_id, legacy_migration_version, body_weight_kg, weight_unit, weight_increment, led_color_scheme_id, equipment_rack_json, workout_preferences_json, vbt_preferences_json FROM UserProfilePreferences ORDER BY profile_id;' & $adb shell run-as $package cat shared_prefs/phoenix_preferences.xml ``` -The host `sqlite3` query must report `user_version` 59, each existing profile row must have `legacy_migration_version = 1`, and the sentinel values from the tracked XML must appear in the corresponding profile preference sections. +The host `sqlite3` query must report `user_version` 60, each existing profile row must have `legacy_migration_version = 1`, and the sentinel values from the tracked XML must appear in the corresponding profile preference sections. diff --git a/scripts/qa/Invoke-PhoenixPersistedFileUpgrade.ps1 b/scripts/qa/Invoke-PhoenixPersistedFileUpgrade.ps1 index e903a146c..a9247a1e8 100644 --- a/scripts/qa/Invoke-PhoenixPersistedFileUpgrade.ps1 +++ b/scripts/qa/Invoke-PhoenixPersistedFileUpgrade.ps1 @@ -28,7 +28,7 @@ param( [string] $EvidenceDirectory = (Join-Path ([IO.Path]::GetTempPath()) 'phoenix-persisted-file-upgrade'), [Parameter()] - [int] $ExpectedSchemaVersion = 59 + [int] $ExpectedSchemaVersion = 60 ) Set-StrictMode -Version Latest diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10PersistenceTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10PersistenceTest.kt new file mode 100644 index 000000000..08fd35d2a --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10PersistenceTest.kt @@ -0,0 +1,358 @@ +package com.devil.phoenixproject.data.ble + +import com.devil.phoenixproject.database.PhoenixDatabase +import com.devil.phoenixproject.data.repository.SqlDelightPolarTelemetryRepository +import com.devil.phoenixproject.data.repository.PolarTelemetryRepository +import com.devil.phoenixproject.domain.model.PolarBeatSample +import com.devil.phoenixproject.domain.model.PolarEcgSegment +import com.devil.phoenixproject.domain.model.PolarRecovery +import com.devil.phoenixproject.domain.model.PolarHrMeasurement +import com.devil.phoenixproject.domain.model.PolarPmdMeasurement +import com.devil.phoenixproject.testutil.createTestDatabase +import com.devil.phoenixproject.testutil.seedExercise +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * End-to-end telemetry persistence test verifying that raw Polar beat and ECG records + * survive foreign-key constraints (PRAGMA foreign_keys = ON) via in-memory buffering + * prior to session row creation and direct persistence during post-workout cooldown. + */ +class PolarH10PersistenceTest { + + private lateinit var database: PhoenixDatabase + private lateinit var testScope: CoroutineScope + private var simulatedTime: Long = 1_000_000L + + @BeforeTest + fun setUp() { + database = createTestDatabase() + testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + database.seedExercise("bench") + } + + @AfterTest + fun tearDown() { + testScope.cancel() + } + + private fun insertWorkoutSession(id: String) { + database.phoenixDatabaseQueries.insertSession( + id = id, + timestamp = 0L, + mode = "OldSchool", + targetReps = 10L, + weightPerCableKg = 40.0, + progressionKg = 0.0, + duration = 0L, + totalReps = 10L, + warmupReps = 0L, + workingReps = 10L, + isJustLift = 0L, + stopAtTop = 0L, + eccentricLoad = 100L, + echoLevel = 1L, + exerciseId = "bench", + exerciseName = "Bench Press", + routineSessionId = null, + routineName = null, + safetyFlags = 0L, + deloadWarningCount = 0L, + romViolationCount = 0L, + spotterActivations = 0L, + peakForceConcentricA = null, + peakForceConcentricB = null, + peakForceEccentricA = null, + peakForceEccentricB = null, + avgForceConcentricA = null, + avgForceConcentricB = null, + avgForceEccentricA = null, + avgForceEccentricB = null, + heaviestLiftKg = null, + totalVolumeKg = null, + cableCount = null, + estimatedCalories = null, + warmupAvgWeightKg = null, + workingAvgWeightKg = null, + burnoutAvgWeightKg = null, + peakWeightKg = null, + rpe = null, + routineId = null, + avgMcvMmS = null, + avgAsymmetryPercent = null, + totalVelocityLossPercent = null, + dominantSide = null, + strengthProfile = null, + formScore = null, + profile_id = "default", + display_multiplier = null, + externalAddedLoadKg = 0.0, + counterweightKg = 0.0, + rackItemsJson = "[]", + ) + } + + @Test + fun `telemetry is buffered before session exists and flushed after session creation with foreign keys ON`() { + val manager = PolarH10BleManager( + telemetryRepository = SqlDelightPolarTelemetryRepository(database, { simulatedTime }, Dispatchers.Unconfined), + scope = testScope, + timeSource = { simulatedTime }, + persistenceDispatcher = Dispatchers.Unconfined, + ) + manager.setRawEcgStorageEnabled(true) + val sessionId = "session-fk-test" + + // 1. Begin recording before WorkoutSession row exists + manager.startSessionRecording(sessionId = sessionId, phase = "WORKING_SET") + + // 2. Feed HR/RR beats and ECG segment while session does not exist in DB + val hrMeasurement = PolarHrMeasurement( + hr = 145, + contactDetected = true, + rrIntervalsMs = listOf(800.0, 820.0), + ) + manager.processRrIntervals(hrMeasurement) + + val ecgMeasurement = PolarPmdMeasurement.Ecg( + sensorTimestampNs = 123456789L, + samplesMicrovolts = listOf(150, 250, 350, 450), + ) + manager.processPmdMeasurement(ecgMeasurement) + + // 3. Verify records are held in memory buffer and not yet in SQLite + assertEquals(2, manager.getBufferedBeatSamplesForTesting().size) + assertEquals(1, manager.getBufferedEcgSegmentsForTesting().size) + val beatRowsBeforeCommit = database.phoenixDatabaseQueries.selectPolarBeatSamplesBySession(sessionId).executeAsList() + val ecgRowsBeforeCommit = database.phoenixDatabaseQueries.selectPolarEcgSegmentsBySession(sessionId).executeAsList() + assertTrue(beatRowsBeforeCommit.isEmpty()) + assertTrue(ecgRowsBeforeCommit.isEmpty()) + + // 4. Now commit the parent WorkoutSession row + insertWorkoutSession(sessionId) + + // 5. Flush buffered telemetry + manager.flushSessionTelemetry(sessionId) + + // 6. Verify in-memory buffers are cleared and rows are durable in SQLite with foreign keys ON + assertEquals(0, manager.getBufferedBeatSamplesForTesting().size) + assertEquals(0, manager.getBufferedEcgSegmentsForTesting().size) + + val beatRowsAfterCommit = database.phoenixDatabaseQueries.selectPolarBeatSamplesBySession(sessionId).executeAsList() + val ecgRowsAfterCommit = database.phoenixDatabaseQueries.selectPolarEcgSegmentsBySession(sessionId).executeAsList() + + assertEquals(2, beatRowsAfterCommit.size) + assertEquals(1, ecgRowsAfterCommit.size) + + val firstBeat = beatRowsAfterCommit[0] + assertEquals(sessionId, firstBeat.sessionId) + assertEquals(145L, firstBeat.hrBpm) + assertEquals(800.0, firstBeat.rrMs) + assertEquals("WORKING_SET", firstBeat.phase) + + val ecgSeg = ecgRowsAfterCommit[0] + assertEquals(sessionId, ecgSeg.sessionId) + assertEquals(123456789L, ecgSeg.sensorTimestampNs) + assertEquals("150,250,350,450", ecgSeg.samplesUvCsv) + + // 7. Post-workout cooldown: subsequent beats are buffered in memory to prevent autocommit storm and flushed when cooldown ends + manager.triggerCooldown(sessionId) + val cooldownBeat = PolarHrMeasurement( + hr = 130, + contactDetected = true, + rrIntervalsMs = listOf(860.0), + ) + manager.processRrIntervals(cooldownBeat) + + assertEquals(1, manager.getBufferedBeatSamplesForTesting().size) + manager.stopCooldown() + assertEquals(0, manager.getBufferedBeatSamplesForTesting().size) + + val beatRowsDuringCooldown = database.phoenixDatabaseQueries.selectPolarBeatSamplesBySession(sessionId).executeAsList() + assertEquals(3, beatRowsDuringCooldown.size) + assertEquals(130L, beatRowsDuringCooldown[2].hrBpm) + } + + @Test + fun `abandoned set discards buffered telemetry cleanly`() { + val manager = PolarH10BleManager( + telemetryRepository = SqlDelightPolarTelemetryRepository(database, { simulatedTime }, Dispatchers.Unconfined), + scope = testScope, + timeSource = { simulatedTime }, + ) + val sessionId = "session-abandoned" + + manager.startSessionRecording(sessionId = sessionId, phase = "WORKING_SET") + manager.processRrIntervals( + PolarHrMeasurement( + hr = 140, + contactDetected = true, + rrIntervalsMs = listOf(800.0), + ) + ) + + assertEquals(1, manager.getBufferedBeatSamplesForTesting().size) + + // Abandon set + manager.discardActiveRecording() + + assertEquals(0, manager.getBufferedBeatSamplesForTesting().size) + assertEquals(null, manager.currentRecordingSessionId) + + val beatRows = database.phoenixDatabaseQueries.selectPolarBeatSamplesBySession(sessionId).executeAsList() + assertTrue(beatRows.isEmpty()) + } + + @Test + fun `queued flush detaches old session before another recording and recovery updates only HRR`() = runTest { + val manager = PolarH10BleManager( + telemetryRepository = SqlDelightPolarTelemetryRepository(database, { simulatedTime }, Dispatchers.Unconfined), + scope = backgroundScope, + timeSource = { simulatedTime }, + persistenceDispatcher = StandardTestDispatcher(testScheduler), + ) + insertWorkoutSession("first") + insertWorkoutSession("second") + database.phoenixDatabaseQueries.updateSessionPolarSummary( + id = "first", avgHeartRateBpm = 150, maxHeartRateBpm = 170, minHeartRateBpm = 130, + avgRrIntervalMs = 800.0, rmssdMs = 25.0, hrr60Bpm = null, hrr300Bpm = null, + maxTorsoPitchDeg = 12.0, maxTorsoRollDeg = 8.0, + ) + manager.startSessionRecording("first") + manager.processRrIntervals(PolarHrMeasurement(150, true, rrIntervalsMs = listOf(800.0))) + val firstFlush = manager.flushSessionTelemetry("first") + assertTrue(database.phoenixDatabaseQueries.selectPolarBeatSamplesBySession("first").executeAsList().isEmpty()) + manager.triggerCooldown("first") + simulatedTime += 65_000L + manager.processRrIntervals(PolarHrMeasurement(120, true, rrIntervalsMs = listOf(1000.0))) + manager.startSessionRecording("second") + manager.processRrIntervals(PolarHrMeasurement(180, true, rrIntervalsMs = listOf(700.0))) + val secondFlush = manager.flushSessionTelemetry("second") + runCurrent() + firstFlush?.join() + secondFlush?.join() + val first = database.phoenixDatabaseQueries.selectSessionById("first").executeAsOne() + assertEquals(150L, first.avgHeartRateBpm) + assertEquals(170L, first.maxHeartRateBpm) + assertEquals(25.0, first.rmssdMs) + assertEquals(12.0, first.maxTorsoPitchDeg) + assertEquals(30L, first.hrr60Bpm) + assertEquals(null, first.hrr300Bpm) + assertEquals(listOf(150L, 120L), database.phoenixDatabaseQueries.selectPolarBeatSamplesBySession("first").executeAsList().map { it.hrBpm }) + assertEquals(listOf(180L), database.phoenixDatabaseQueries.selectPolarBeatSamplesBySession("second").executeAsList().map { it.hrBpm }) + } + + @Test + fun `failed telemetry transaction remains retryable after parent row is committed`() = runTest { + insertWorkoutSession("retry") + val delegate = SqlDelightPolarTelemetryRepository(database, { simulatedTime }, Dispatchers.Unconfined) + var failNextSave = true + val repository = object : PolarTelemetryRepository by delegate { + override suspend fun saveTelemetry( + sessionId: String, + beats: List, + ecgs: List, + recovery: PolarRecovery?, + ) { + if (failNextSave) { + failNextSave = false + error("Transient telemetry storage failure") + } + delegate.saveTelemetry(sessionId, beats, ecgs, recovery) + } + } + val manager = PolarH10BleManager( + telemetryRepository = repository, + scope = backgroundScope, + timeSource = { simulatedTime }, + persistenceDispatcher = StandardTestDispatcher(testScheduler), + ) + manager.startSessionRecording("retry") + manager.processRrIntervals(PolarHrMeasurement(145, true, rrIntervalsMs = listOf(800.0))) + manager.flushSessionTelemetry("retry") + runCurrent() + assertTrue(manager.telemetryPersistenceError.value != null) + manager.flushSessionTelemetry("retry") + runCurrent() + assertEquals(null, manager.telemetryPersistenceError.value) + assertEquals(1, database.phoenixDatabaseQueries.selectPolarBeatSamplesBySession("retry").executeAsList().size) + } + + @Test + fun `late manual stop commit remaps only its source buffer after next recording starts`() = runTest { + val manager = PolarH10BleManager( + telemetryRepository = SqlDelightPolarTelemetryRepository(database, { simulatedTime }, Dispatchers.Unconfined), + scope = backgroundScope, timeSource = { simulatedTime }, + persistenceDispatcher = StandardTestDispatcher(testScheduler), + ) + manager.startSessionRecording("source") + manager.processRrIntervals(PolarHrMeasurement(145, true, rrIntervalsMs = listOf(800.0))) + assertEquals(145, manager.freezeSessionRecording("source")?.avgHeartRateBpm) + manager.processRrIntervals(PolarHrMeasurement(160, true, rrIntervalsMs = listOf(750.0))) + manager.startSessionRecording("next") + manager.processRrIntervals(PolarHrMeasurement(180, true, rrIntervalsMs = listOf(650.0))) + assertEquals(145, manager.getSessionSummary("source")?.avgHeartRateBpm) + insertWorkoutSession("committed") + manager.flushSessionTelemetry("committed", recordedSourceId = "source") + runCurrent() + assertEquals("next", manager.currentRecordingSessionId) + assertEquals("next", manager.lastRecordedSessionId.value) + assertEquals(1, manager.getBufferedBeatSamplesForTesting().size) + val stored = database.phoenixDatabaseQueries.selectPolarBeatSamplesBySession("committed").executeAsList() + assertEquals(listOf(145L), stored.map { it.hrBpm }) + } + + @Test + fun `precommit cooldown waits for matching parent commit and remaps the source`() = runTest { + val manager = PolarH10BleManager( + telemetryRepository = SqlDelightPolarTelemetryRepository(database, { simulatedTime }, Dispatchers.Unconfined), + scope = backgroundScope, timeSource = { simulatedTime }, + persistenceDispatcher = StandardTestDispatcher(testScheduler), + ) + manager.startSessionRecording("uncommitted") + manager.processRrIntervals(PolarHrMeasurement(150, true, rrIntervalsMs = listOf(800.0))) + manager.freezeSessionRecording("uncommitted") + manager.triggerCooldown("uncommitted") + runCurrent() + assertTrue(!manager.isCooldownActive.value) + assertEquals(1, manager.getBufferedBeatSamplesForTesting().size) + assertEquals(null, manager.telemetryPersistenceError.value) + insertWorkoutSession("saved") + manager.flushSessionTelemetry("saved", recordedSourceId = "uncommitted") + runCurrent() + assertTrue(manager.isCooldownActive.value) + assertEquals("saved", manager.activeCooldownSessionId.value) + assertEquals(1, database.phoenixDatabaseQueries.selectPolarBeatSamplesBySession("saved").executeAsList().size) + manager.cancelCooldown() + runCurrent() + } + + @Test + fun `next recording supersedes cooldown request before old parent commit`() = runTest { + val manager = PolarH10BleManager( + telemetryRepository = SqlDelightPolarTelemetryRepository(database, { simulatedTime }, Dispatchers.Unconfined), + scope = backgroundScope, timeSource = { simulatedTime }, + persistenceDispatcher = StandardTestDispatcher(testScheduler), + ) + manager.startSessionRecording("previous") + manager.processRrIntervals(PolarHrMeasurement(145, true, rrIntervalsMs = listOf(800.0))) + manager.triggerCooldown("previous") + manager.startSessionRecording("current") + insertWorkoutSession("previous") + manager.flushSessionTelemetry("previous") + runCurrent() + assertTrue(!manager.isCooldownActive.value) + assertEquals("current", manager.currentRecordingSessionId) + assertEquals("current", manager.lastRecordedSessionId.value) + } +} diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt index a85465149..53f8bee14 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt @@ -1302,10 +1302,36 @@ class SchemaParityTest { } } + @Test + fun `migration 59 adds Polar tables and columns to MetricSample and WorkoutSession`() { + listOf("generated" to false, "fallback" to true).forEach { (scenario, fallback) -> + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + buildSchemaAtVersion(driver, 59) + assertEquals(false, getTables(driver).contains("PolarBeatSample"), scenario) + assertEquals(false, getTables(driver).contains("PolarEcgSegment"), scenario) + + if (fallback) { + val results = applyMigrationResilient(driver, 59) + assertEquals(true, results.all { it.success }, scenario) + } else { + PhoenixDatabase.Schema.migrate(driver, 59, 60) + } + + assertEquals(true, getTables(driver).contains("PolarBeatSample"), scenario) + assertEquals(true, getTables(driver).contains("PolarEcgSegment"), scenario) + assertEquals(true, columnExistsInDriver(driver, "MetricSample", "heartRateBpm"), scenario) + assertEquals(true, columnExistsInDriver(driver, "MetricSample", "torsoPitchDeg"), scenario) + assertEquals(true, columnExistsInDriver(driver, "MetricSample", "chestAccXMg"), scenario) + assertEquals(true, columnExistsInDriver(driver, "WorkoutSession", "avgHeartRateBpm"), scenario) + assertEquals(true, columnExistsInDriver(driver, "WorkoutSession", "maxTorsoPitchDeg"), scenario) + assertEquals(true, columnExistsInDriver(driver, "WorkoutSession", "hrr60Bpm"), scenario) + } + } + // ==================== HELPERS ==================== companion object { - private const val EXPECTED_SCHEMA_VERSION = 59L + private const val EXPECTED_SCHEMA_VERSION = 60L /** * A database built the way every existing user gets it: minimal v1 schema, each diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightPolarTelemetryRepositoryTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightPolarTelemetryRepositoryTest.kt new file mode 100644 index 000000000..6184b30bd --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightPolarTelemetryRepositoryTest.kt @@ -0,0 +1,140 @@ +package com.devil.phoenixproject.data.repository + +import com.devil.phoenixproject.database.PhoenixDatabase +import com.devil.phoenixproject.domain.model.PolarBeatSample +import com.devil.phoenixproject.domain.model.PolarEcgSegment +import com.devil.phoenixproject.domain.model.PolarRecovery +import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.testutil.FakeExerciseRepository +import com.devil.phoenixproject.testutil.createTestDriver +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SqlDelightPolarTelemetryRepositoryTest { + @Test + fun `a batch for a deleted session is discarded and the next valid batch saves`() = runTest { + val driver = createTestDriver() + try { + val database = PhoenixDatabase(driver) + val workouts = SqlDelightWorkoutRepository(database, FakeExerciseRepository()) + val repository = SqlDelightPolarTelemetryRepository( + database, { NOW_MS }, StandardTestDispatcher(testScheduler), + ) + workouts.saveSession(session("deleted")) + workouts.saveSession(session("valid")) + database.phoenixDatabaseQueries.hardDeleteWorkoutComponent("deleted") + + repository.saveTelemetry("deleted", listOf(beat("deleted")), listOf(ecg("deleted")), PolarRecovery(20, 30)) + repository.saveTelemetry("valid", listOf(beat("valid")), listOf(ecg("valid")), PolarRecovery(15, 25)) + + val queries = database.phoenixDatabaseQueries + assertTrue(queries.selectPolarBeatSamplesBySession("deleted").executeAsList().isEmpty()) + assertTrue(queries.selectPolarEcgSegmentsBySession("deleted").executeAsList().isEmpty()) + assertEquals(145L, queries.selectPolarBeatSamplesBySession("valid").executeAsOne().hrBpm) + assertEquals("100,105", queries.selectPolarEcgSegmentsBySession("valid").executeAsOne().samplesUvCsv) + assertEquals(15L, queries.selectSessionById("valid").executeAsOne().hrr60Bpm) + assertEquals(25L, queries.selectSessionById("valid").executeAsOne().hrr300Bpm) + } finally { + driver.close() + } + } + + @Test + fun `save and standalone prune enforce the thirty day cutoff`() = runTest { + val driver = createTestDriver() + try { + val database = PhoenixDatabase(driver) + SqlDelightWorkoutRepository(database, FakeExerciseRepository()).saveSession(session("retention")) + var now = NOW_MS + val repository = SqlDelightPolarTelemetryRepository( + database, { now }, StandardTestDispatcher(testScheduler), + ) + val cutoff = now - SqlDelightPolarTelemetryRepository.RAW_ECG_RETENTION_DAYS * 86_400_000L + + repository.saveTelemetry( + "retention", + emptyList(), + listOf( + ecg("retention").copy(wallClockTimestampMs = cutoff - 1, sensorTimestampNs = 1), + ecg("retention").copy(wallClockTimestampMs = cutoff, sensorTimestampNs = 2), + ecg("retention").copy(wallClockTimestampMs = now, sensorTimestampNs = 3), + ), + ) + + assertEquals( + listOf(2L, 3L), + database.phoenixDatabaseQueries.selectPolarEcgSegmentsBySession("retention").executeAsList() + .map { it.sensorTimestampNs }, + ) + now += 31 * 86_400_000L + repository.pruneRawEcg() + assertTrue(database.phoenixDatabaseQueries.selectPolarEcgSegmentsBySession("retention").executeAsList().isEmpty()) + } finally { + driver.close() + } + } + + @Test + fun `save retains only the newest thirty six thousand ECG segments`() = runTest { + val driver = createTestDriver() + try { + val database = PhoenixDatabase(driver) + SqlDelightWorkoutRepository(database, FakeExerciseRepository()).saveSession(session("cap")) + val repository = SqlDelightPolarTelemetryRepository( + database, { NOW_MS }, StandardTestDispatcher(testScheduler), + ) + val count = SqlDelightPolarTelemetryRepository.MAX_STORED_ECG_SEGMENTS.toInt() + 2 + val segments = List(count) { index -> + ecg("cap").copy(wallClockTimestampMs = NOW_MS - count + index, sensorTimestampNs = index.toLong()) + } + + repository.saveTelemetry("cap", emptyList(), segments) + + val retained = database.phoenixDatabaseQueries.selectPolarEcgSegmentsBySession("cap").executeAsList() + assertEquals(36_000, retained.size) + assertEquals(2L, retained.first().sensorTimestampNs) + assertEquals((count - 1).toLong(), retained.last().sensorTimestampNs) + } finally { + driver.close() + } + } + + private fun session(sessionId: String) = WorkoutSession( + id = sessionId, + timestamp = NOW_MS, + mode = "OldSchool", + reps = 10, + weightPerCableKg = 25f, + totalReps = 10, + workingReps = 10, + ) + + private fun beat(sessionId: String) = PolarBeatSample( + id = 900, + sessionId = sessionId, + timestampMs = NOW_MS, + phase = "WORKING_SET", + hrBpm = 145, + rrMs = 800.0, + rmssdMs = null, + torsoPitchDeg = null, + torsoRollDeg = null, + dynamicAccMg = null, + ) + + private fun ecg(sessionId: String) = PolarEcgSegment( + id = 901, + sessionId = sessionId, + sensorTimestampNs = 1, + wallClockTimestampMs = NOW_MS, + phase = "WORKING_SET", + samplesUvCsv = "100,105", + ) + + companion object { + private const val NOW_MS = 1_700_000_000_000L + } +} diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCardRuntimeTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCardRuntimeTest.kt new file mode 100644 index 000000000..5d93d58af --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCardRuntimeTest.kt @@ -0,0 +1,77 @@ +package com.devil.phoenixproject.presentation.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.devil.phoenixproject.data.repository.WorkoutRepository +import com.devil.phoenixproject.data.ble.PolarH10BleManager +import com.devil.phoenixproject.data.ble.PolarSessionSummary +import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.domain.model.WorkoutSession +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class PolarSessionAnalyticsCardRuntimeTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun sessionWithoutPolarSummaryDoesNotReadSampleTables() { + val session = WorkoutSession(id = "without-polar") + val repository = mockk(relaxed = true) + coEvery { repository.getSession(session.id) } returns session + val manager = mockk(relaxed = true) + every { manager.connectionState } returns MutableStateFlow(ConnectionState.Connected("Polar", "sensor")) + every { manager.isCooldownActive } returns MutableStateFlow(true) + every { manager.cooldownSecondsRemaining } returns MutableStateFlow(240) + every { manager.cooldownStartHeartRate } returns MutableStateFlow(100) + every { manager.latestSessionSummary } returns MutableStateFlow(PolarSessionSummary(sessionId = "other", hrr60Bpm = 20)) + every { manager.hrMeasurement } returns MutableStateFlow(null) + every { manager.activeCooldownSessionId } returns MutableStateFlow("other") + every { manager.lastRecordedSessionId } returns MutableStateFlow("other") + every { manager.currentRecordingSessionId } returns "other" + every { manager.getSessionSummary(session.id) } returns null + + rule.setContent { + MaterialTheme { + PolarSessionAnalyticsCard(session = session, showCooldown = false, workoutRepository = repository, polarBleManager = manager) + } + } + rule.waitForIdle() + + rule.onNodeWithText("Cardiac & Biomechanics Summary").assertDoesNotExist() + coVerify(exactly = 0) { repository.getPolarBeatSamplesBySession(any()) } + coVerify(exactly = 0) { repository.getPolarEcgSegmentsBySession(any()) } + coVerify(exactly = 0) { repository.getMetricSamplesBySession(any()) } + } + + @Test + fun historicalRecoveryOnlySessionShowsStoredRecoveryWithoutCooldownControls() { + val session = WorkoutSession(id = "recovery-only", hrr60Bpm = 17) + val repository = mockk(relaxed = true) + coEvery { repository.getSession(session.id) } returns session + + rule.setContent { + MaterialTheme { + PolarSessionAnalyticsCard(session = session, showCooldown = false, workoutRepository = repository) + } + } + rule.waitForIdle() + + rule.onNodeWithText("1-Min Recovery (HRR-60)").assertExists() + rule.onNodeWithText("Start Cooldown").assertDoesNotExist() + rule.onNodeWithText("Restart Cooldown").assertDoesNotExist() + } +} diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ConnectionLogsViewModelTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ConnectionLogsViewModelTest.kt index e1e21e020..74fb34d5e 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ConnectionLogsViewModelTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ConnectionLogsViewModelTest.kt @@ -84,4 +84,31 @@ class ConnectionLogsViewModelTest { cancelAndIgnoreRemainingEvents() } } + + @Test + fun `filters logs by hrmOnly`() = runTest { + viewModel.logs.test { + // Initial empty state + assertEquals(emptyList(), awaitItem()) + + // Add logs + repository.info(LogEventType.CONNECT_SUCCESS, "Connected to Vitruvian", deviceName = "Vee_Trainer") + repository.info(LogEventType.CONNECT_SUCCESS, "[HRM] Connected to Polar H10", deviceName = "Polar H10", tag = "HRM") + advanceUntilIdle() + + val withBothLogs = awaitItem() + assertEquals(2, withBothLogs.size) + + // Toggle hrmOnly filter + viewModel.toggleHrmOnly() + advanceUntilIdle() + + val filtered = awaitItem() + assertEquals(1, filtered.size) + assertEquals("HRM", filtered.first().tag) + assertTrue(filtered.first().message.contains("Polar H10")) + + cancelAndIgnoreRemainingEvents() + } + } } diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ExerciseConfigViewModelTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ExerciseConfigViewModelTest.kt index ed34587a1..546c44354 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ExerciseConfigViewModelTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ExerciseConfigViewModelTest.kt @@ -306,41 +306,50 @@ class ExerciseConfigViewModelTest { @Test fun `manual PR percent weight edit before PR load converts when PR becomes available`() = runTest { val repository = FakePersonalRecordRepository() - val viewModel = createViewModel(personalRecordRepository = repository) - val exercise = benchRoutineExercise( - id = "rex-pr-pending", - setReps = listOf(10, 10), - weightPerCableKg = 5f, - setWeightsPerCableKg = listOf(5f, 5f), - usePercentOfPR = true, - weightPercentOfPR = 80, - setWeightsPercentOfPR = listOf(80, 80), - ) + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val viewModel = createViewModel(personalRecordRepository = repository) + try { + val exercise = benchRoutineExercise( + id = "rex-pr-pending", + setReps = listOf(10, 10), + weightPerCableKg = 5f, + setWeightsPerCableKg = listOf(5f, 5f), + usePercentOfPR = true, + weightPercentOfPR = 80, + setWeightsPercentOfPR = listOf(80, 80), + ) - viewModel.initialize( - exercise = exercise, - unit = WeightUnit.KG, - toDisplay = { value, _ -> value }, - toKg = { value, _ -> value }, - ) - advanceUntilIdle() - assertNull(viewModel.currentExercisePR.value) + viewModel.initialize( + exercise = exercise, + unit = WeightUnit.KG, + toDisplay = { value, _ -> value }, + toKg = { value, _ -> value }, + ) + advanceUntilIdle() + assertNull(viewModel.currentExercisePR.value) - viewModel.updateWeight(viewModel.sets.value.first().id, 25f) - assertEquals(listOf(25f, 5f), viewModel.sets.value.map { it.weightPerCable }) + viewModel.updateWeight(viewModel.sets.value.first().id, 25f) + assertEquals(listOf(25f, 5f), viewModel.sets.value.map { it.weightPerCable }) - repository.addRecord(weightPR(weight = 50f)) - viewModel.loadPRForExercise("bench-1", "Old School") - advanceUntilIdle() + repository.addRecord(weightPR(weight = 50f)) + viewModel.loadPRForExercise("bench-1", "Old School") + advanceUntilIdle() - waitForCondition { viewModel.sets.value.map { it.weightPerCable } == listOf(25f, 40f) } + assertEquals(listOf(25f, 40f), viewModel.sets.value.map { it.weightPerCable }) - var saved: RoutineExercise? = null - viewModel.onSave { updated -> saved = updated } + var saved: RoutineExercise? = null + viewModel.onSave { updated -> saved = updated } - assertNotNull(saved) - assertEquals(listOf(50, 80), saved.setWeightsPercentOfPR) - assertEquals(listOf(25f, 40f), saved.setWeightsPerCableKg) + assertNotNull(saved) + assertEquals(listOf(50, 80), saved.setWeightsPercentOfPR) + assertEquals(listOf(25f, 40f), saved.setWeightsPerCableKg) + } finally { + viewModel.viewModelScope.cancel() + } + } finally { + Dispatchers.resetMain() + } } @Test diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupCompletenessTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupCompletenessTest.kt index 5e87c7812..927e622d8 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupCompletenessTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupCompletenessTest.kt @@ -241,6 +241,229 @@ class DataBackupCompletenessTest { assertEquals(150.0, target.queries.selectExerciseById("stock-press").executeAsOne().mvtOverrideMs, "local override wins") } + // ---- polar telemetry completeness ---- + + @Test + fun `polar summaries always round trip and raw samples and ecg traces require export opt in`() = runTest { + val source = Fixture(includeRawTelemetry = false) + source.seedProfiles() + source.database.seedExercise("bench", "Bench Press") + source.saveSession("session-polar-1") + source.queries.updateSessionPolarSummary( + id = "session-polar-1", + avgHeartRateBpm = 142L, + maxHeartRateBpm = 168L, + minHeartRateBpm = 95L, + avgRrIntervalMs = 750.5, + rmssdMs = 45.2, + hrr60Bpm = 25L, + hrr300Bpm = 50L, + maxTorsoPitchDeg = 32.5, + maxTorsoRollDeg = 15.1, + ) + source.queries.insertMetric( + sessionId = "session-polar-1", + timestamp = 1_700_000_001_000L, + position = 100.0, + positionB = 100.0, + velocity = 200.0, + velocityB = 200.0, + load = 25.0, + loadB = 25.0, + power = 50.0, + status = 0L, + ) + source.queries.insertMetricWithPolar( + sessionId = "session-polar-1", + timestamp = 1_700_000_002_000L, + position = 100.0, + positionB = 100.0, + velocity = 200.0, + velocityB = 200.0, + load = 25.0, + loadB = 25.0, + power = 50.0, + status = 0L, + heartRateBpm = 145L, + rrIntervalMs = 740.0, + torsoPitchDeg = 30.0, + torsoRollDeg = 12.0, + dynamicAccMg = 150L, + chestAccXMg = 100L, + chestAccYMg = 200L, + chestAccZMg = 950L, + ) + // Polar beat sample + source.queries.insertPolarBeatSample( + sessionId = "session-polar-1", + timestampMs = 1_700_000_002_500L, + phase = "WORKING_SET", + hrBpm = 145L, + rrMs = 740.0, + rmssdMs = 45.2, + torsoPitchDeg = 30.0, + torsoRollDeg = 12.0, + dynamicAccMg = 150L, + ) + // Polar ECG segment + source.queries.insertPolarEcgSegment( + sessionId = "session-polar-1", + sensorTimestampNs = 1_000_000_000L, + wallClockTimestampMs = 1_700_000_000_000L, + phase = "WORKING_SET", + samplesUvCsv = "100,105,110", + ) + + // Export (with includeRawTelemetry = false) + val exportedJson = source.manager.exportToJson() + val backupData = testJson.decodeFromString(exportedJson) + + assertEquals(0, backupData.data.polarBeatSamples.size) + assertEquals(0, backupData.data.polarEcgSegments.size) + assertEquals(0, backupData.data.metricSamples.size) + + val target = Fixture(includeRawTelemetry = false) + val result = target.manager.importFromJson(exportedJson).getOrThrow() + assertEquals(0, result.entitiesWithErrors) + assertEquals(0, result.polarBeatSamplesImported) + assertEquals(0, result.polarEcgSegmentsImported) + + // Verify target database has summary stats preserved + val targetSession = target.queries.selectSessionById("session-polar-1").executeAsOne() + assertEquals(142L, targetSession.avgHeartRateBpm) + assertEquals(168L, targetSession.maxHeartRateBpm) + assertEquals(95L, targetSession.minHeartRateBpm) + assertEquals(45.2f, targetSession.rmssdMs?.toFloat()) + assertEquals(32.5f, targetSession.maxTorsoPitchDeg?.toFloat()) + + val targetMetrics = target.queries.selectMetricsBySession("session-polar-1").executeAsList() + assertEquals(0, targetMetrics.size) + val targetBeats = target.queries.selectPolarBeatSamplesBySession("session-polar-1").executeAsList() + assertEquals(0, targetBeats.size) + val targetEcgs = target.queries.selectPolarEcgSegmentsBySession("session-polar-1").executeAsList() + assertEquals(0, targetEcgs.size) + + // Export (with includeRawTelemetry = true) + val sourceWithTelemetry = Fixture(includeRawTelemetry = true) + sourceWithTelemetry.seedProfiles() + sourceWithTelemetry.saveSession("session-polar-2") + sourceWithTelemetry.queries.updateSessionPolarSummary( + id = "session-polar-2", + avgHeartRateBpm = 142L, + maxHeartRateBpm = 168L, + minHeartRateBpm = 95L, + avgRrIntervalMs = 740.0, + rmssdMs = 45.2, + hrr60Bpm = 25L, + hrr300Bpm = 50L, + maxTorsoPitchDeg = 32.5, + maxTorsoRollDeg = 12.0, + ) + sourceWithTelemetry.insertMetric("session-polar-2") + sourceWithTelemetry.queries.insertPolarBeatSample( + sessionId = "session-polar-2", + timestampMs = 1_000L, + phase = "WORKING_SET", + hrBpm = 145L, + rrMs = 740.0, + rmssdMs = 45.2, + torsoPitchDeg = 32.5, + torsoRollDeg = 12.0, + dynamicAccMg = 150L, + ) + sourceWithTelemetry.queries.insertPolarEcgSegment( + sessionId = "session-polar-2", + sensorTimestampNs = 1_000_000_000L, + wallClockTimestampMs = 1_700_000_000_000L, + phase = "WORKING_SET", + samplesUvCsv = "100,105,110", + ) + val fullJson = sourceWithTelemetry.manager.exportToJson() + val fullData = testJson.decodeFromString(fullJson) + assertEquals(1, fullData.data.polarBeatSamples.size) + assertEquals(1, fullData.data.polarEcgSegments.size) + assertEquals(1, fullData.data.metricSamples.size) + + val fullTarget = Fixture(includeRawTelemetry = false) + val fullResult = fullTarget.manager.importFromJson(fullJson).getOrThrow() + assertEquals(1, fullResult.polarBeatSamplesImported) + assertEquals(1, fullResult.polarEcgSegmentsImported) + assertEquals(1, fullTarget.queries.selectMetricsBySession("session-polar-2").executeAsList().size) + assertEquals(1, fullTarget.queries.selectPolarBeatSamplesBySession("session-polar-2").executeAsList().size) + assertEquals(1, fullTarget.queries.selectPolarEcgSegmentsBySession("session-polar-2").executeAsList().size) + } + + @Test + fun `polar restore skips exact duplicates and reports differing or cross session id conflicts`() = runTest { + val source = Fixture() + source.seedProfiles() + source.saveSession("polar-session-1") + source.saveSession("polar-session-2") + val beat = PolarBeatSampleBackup( + id = 42L, + sessionId = "polar-session-1", + timestampMs = 1_700_000_002_500L, + phase = "WORKING_SET", + hrBpm = 145, + rrMs = 740f, + rmssdMs = 45.2f, + torsoPitchDeg = 30f, + torsoRollDeg = 12f, + dynamicAccMg = 150, + ) + val segment = PolarEcgSegmentBackup( + id = 57L, + sessionId = "polar-session-1", + sensorTimestampNs = 1_000_000_000L, + wallClockTimestampMs = 1_700_000_000_000L, + phase = "WORKING_SET", + samplesUvCsv = "100,105,110", + ) + val exported = testJson.decodeFromString(source.manager.exportToJson()) + val backup = exported.copy( + data = exported.data.copy(polarBeatSamples = listOf(beat), polarEcgSegments = listOf(segment)), + ) + val target = Fixture() + val encoded = testJson.encodeToString(backup) + + val imported = target.manager.importFromJson(encoded).getOrThrow() + assertEquals(0, imported.entitiesWithErrors) + assertEquals(1, imported.polarBeatSamplesImported) + assertEquals(1, imported.polarEcgSegmentsImported) + val originalBeat = target.queries.selectPolarBeatSampleById(beat.id).executeAsOne() + val originalSegment = target.queries.selectPolarEcgSegmentById(segment.id).executeAsOne() + + val duplicate = target.manager.importFromJson(encoded).getOrThrow() + assertEquals(0, duplicate.entitiesWithErrors) + assertEquals(1, duplicate.polarBeatSamplesSkipped) + assertEquals(1, duplicate.polarEcgSegmentsSkipped) + assertEquals(0, duplicate.polarBeatSamplesImported) + assertEquals(0, duplicate.polarEcgSegmentsImported) + + listOf( + beat.copy(hrBpm = 146) to segment.copy(samplesUvCsv = "999"), + beat.copy(sessionId = "polar-session-2") to segment.copy(sessionId = "polar-session-2"), + ).forEach { (conflictingBeat, conflictingSegment) -> + val conflicting = backup.copy( + data = backup.data.copy( + polarBeatSamples = listOf(conflictingBeat), + polarEcgSegments = listOf(conflictingSegment), + ), + ) + val result = target.manager.importFromJson(testJson.encodeToString(conflicting)).getOrThrow() + + assertEquals(2, result.entitiesWithErrors) + assertEquals(0, result.polarBeatSamplesSkipped) + assertEquals(0, result.polarEcgSegmentsSkipped) + assertEquals(0, result.polarBeatSamplesImported) + assertEquals(0, result.polarEcgSegmentsImported) + assertEquals(originalBeat, target.queries.selectPolarBeatSampleById(beat.id).executeAsOne()) + assertEquals(originalSegment, target.queries.selectPolarEcgSegmentById(segment.id).executeAsOne()) + assertTrue(target.queries.selectPolarBeatSamplesBySession("polar-session-2").executeAsList().isEmpty()) + assertTrue(target.queries.selectPolarEcgSegmentsBySession("polar-session-2").executeAsList().isEmpty()) + } + } + // ---- restore resets settings-held sync state ---- @Test diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/di/PlatformModule.android.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/di/PlatformModule.android.kt index 51b2abbb5..18bf26e7a 100644 --- a/shared/src/androidMain/kotlin/com/devil/phoenixproject/di/PlatformModule.android.kt +++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/di/PlatformModule.android.kt @@ -121,6 +121,7 @@ actual val platformModule: Module = module { machineSafetyCoordinator = get(), profileRecoveryActivityTracker = get(), recentJustLiftExerciseStore = get(), + polarBleManager = getOrNull(), ) } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt new file mode 100644 index 000000000..1e4b06840 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -0,0 +1,1991 @@ +package com.devil.phoenixproject.data.ble + +import co.touchlab.kermit.Logger +import com.devil.phoenixproject.data.repository.ConnectionLogRepository +import com.devil.phoenixproject.data.repository.LogEventType +import com.devil.phoenixproject.data.repository.ScannedDevice +import com.devil.phoenixproject.data.repository.PolarTelemetryRepository +import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.domain.model.PolarHrMeasurement +import com.devil.phoenixproject.domain.model.PolarBeatSample +import com.devil.phoenixproject.domain.model.PolarEcgSegment +import com.devil.phoenixproject.domain.model.PolarRecovery +import com.devil.phoenixproject.domain.model.PolarPmdMeasurement +import com.devil.phoenixproject.domain.model.PolarRrBeat +import com.devil.phoenixproject.domain.model.currentTimeMillis +import com.devil.phoenixproject.util.BleConstants +import com.devil.phoenixproject.util.PolarAccFilter +import com.devil.phoenixproject.util.PolarTelemetryUtils +import com.devil.phoenixproject.util.rethrowIfCancellation +import com.juul.kable.Advertisement +import com.juul.kable.Peripheral +import com.juul.kable.Scanner +import com.juul.kable.State +import com.juul.kable.WriteType +import com.russhwolf.settings.Settings +import kotlin.concurrent.Volatile +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlin.time.TimeSource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.filter +import kotlinx.atomicfu.locks.reentrantLock +import kotlinx.atomicfu.locks.withLock +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.withContext + +/** + * Summary metrics for a session recorded by Polar H10. + */ +data class PolarSessionSummary( + val sessionId: String? = null, + val avgHeartRateBpm: Int? = null, + val maxHeartRateBpm: Int? = null, + val minHeartRateBpm: Int? = null, + val avgRrIntervalMs: Float? = null, + val rmssdMs: Float? = null, + val hrr60Bpm: Int? = null, + val hrr300Bpm: Int? = null, + val maxTorsoPitchDeg: Float? = null, + val maxTorsoRollDeg: Float? = null, +) + +/** + * Multiplatform BLE Manager for Polar H10 heart rate sensors. + * + * Implements independent connection lifecycle, GATT service discovery, + * standard Heart Rate (0x180D) and Battery (0x180F) telemetry, and proprietary + * Polar Measurement Data (PMD) streaming for ECG and Accelerometer (ACC). + * + * Designed to operate simultaneously alongside [KableBleConnectionManager] + * without cross-peripheral interference. + */ +class PolarH10BleManager( + private val settings: Settings? = null, + private val telemetryRepository: PolarTelemetryRepository? = null, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + private val timeSource: () -> Long = DEFAULT_WALL_CLOCK, + monotonicTimeSource: (() -> Long)? = null, + private val logRepo: ConnectionLogRepository = ConnectionLogRepository.instance, + private val advertisements: () -> Flow = { Scanner {}.advertisements }, + private val createPeripheral: (Advertisement) -> Peripheral = { Peripheral(it) }, + private val persistenceDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + private val log = Logger.withTag("PolarH10BleManager") + + companion object { + private val monotonicOrigin = TimeSource.Monotonic.markNow() + val DEFAULT_MONOTONIC_CLOCK: () -> Long = { monotonicOrigin.elapsedNow().inWholeMilliseconds } + val DEFAULT_WALL_CLOCK: () -> Long = ::currentTimeMillis + const val KEY_POLAR_REMEMBERED_ADDRESS = "polar_h10_remembered_address" + const val KEY_POLAR_REMEMBERED_NAME = "polar_h10_remembered_name" + const val KEY_POLAR_AUTO_RECONNECT = "polar_h10_auto_reconnect_enabled" + const val CRITICAL_BATTERY_PERCENTAGE = 5 + const val ECG_RAIL_THRESHOLD_UV = 15_000 + const val KEY_RAW_ECG_STORAGE = "polar_h10_raw_ecg_storage_enabled" + const val MAX_RAW_ECG_DURATION_MS = 300_000L + const val RAW_ECG_RETENTION_DAYS = 30 + const val MAX_STORED_ECG_SEGMENTS = 36_000L + private const val MAX_RAW_ECG_SAMPLES = 39_000 + private const val CONTACT_LOSS_DEBOUNCE_MS = 3_000L + } + + private val monotonicClock: () -> Long = monotonicTimeSource + ?: if (timeSource === DEFAULT_WALL_CLOCK) DEFAULT_MONOTONIC_CLOCK else timeSource + private val wallClock: () -> Long = timeSource + + // ===== Remembered Device & Auto-Reconnect ===== + private val _rememberedAddress = MutableStateFlow(settings?.getStringOrNull(KEY_POLAR_REMEMBERED_ADDRESS)) + val rememberedAddress: StateFlow = _rememberedAddress.asStateFlow() + + private val _rememberedName = MutableStateFlow(settings?.getStringOrNull(KEY_POLAR_REMEMBERED_NAME)) + val rememberedName: StateFlow = _rememberedName.asStateFlow() + + private val _isAutoReconnectEnabled = MutableStateFlow( + settings?.getBoolean(KEY_POLAR_AUTO_RECONNECT, defaultValue = true) ?: true, + ) + val isAutoReconnectEnabled: StateFlow = _isAutoReconnectEnabled.asStateFlow() + + fun setAutoReconnectEnabled(enabled: Boolean) { + settings?.putBoolean(KEY_POLAR_AUTO_RECONNECT, enabled) + _isAutoReconnectEnabled.value = enabled + } + + private val _isRawEcgStorageEnabled = MutableStateFlow( + settings?.getBoolean(KEY_RAW_ECG_STORAGE, false) ?: false, + ) + val isRawEcgStorageEnabled: StateFlow = _isRawEcgStorageEnabled.asStateFlow() + + fun setRawEcgStorageEnabled(enabled: Boolean) { + settings?.putBoolean(KEY_RAW_ECG_STORAGE, enabled) + sessionLock.withLock { + _isRawEcgStorageEnabled.value = enabled + if (!enabled) bufferedEcgSegments.clear() + } + } + + fun forgetRememberedDevice() { + settings?.remove(KEY_POLAR_REMEMBERED_ADDRESS) + settings?.remove(KEY_POLAR_REMEMBERED_NAME) + _rememberedAddress.value = null + _rememberedName.value = null + reconnectJob?.cancel() + reconnectJob = null + } + + // ===== Torso Biomechanics Posture & Filtering ===== + private val accFilter = PolarAccFilter() + + private val _torsoPitchDeg = MutableStateFlow(null) + val torsoPitchDeg: StateFlow = _torsoPitchDeg.asStateFlow() + + private val _torsoRollDeg = MutableStateFlow(null) + val torsoRollDeg: StateFlow = _torsoRollDeg.asStateFlow() + + private val _dynamicAccMg = MutableStateFlow(null) + val dynamicAccMg: StateFlow = _dynamicAccMg.asStateFlow() + + fun tareCurrentPosture() { + accFilter.tareCurrentPosture() + } + + fun resetPostureTare() { + accFilter.tarePitchDeg = 0.0 + accFilter.tareRollDeg = 0.0 + } + + // ===== Session Recording & Recovery State ===== + internal data class BufferedBeatSample( + val timestampMs: Long, + val phase: String, + val hrBpm: Long, + val rrMs: Double, + val rmssdMs: Double?, + val torsoPitchDeg: Double?, + val torsoRollDeg: Double?, + val dynamicAccMg: Long?, + ) + + internal data class BufferedEcgSegment( + val sensorTimestampNs: Long, + val wallClockTimestampMs: Long, + val phase: String, + val samplesUvCsv: String, + ) + + private val sessionLock = reentrantLock() + private var activeSessionId: String? = null + private var recordedSessionId: String? = null + private var currentPhase: String = "WARMUP" + private var cooldownJob: Job? = null + private val sessionHrs = mutableListOf() + private val sessionRrs = mutableListOf() + private val recentRrs = ArrayDeque() + private val bufferedBeatSamples = mutableListOf() + private val bufferedEcgSegments = mutableListOf() + private var maxTorsoPitch: Double? = null + private var maxTorsoRoll: Double? = null + private var endHeartRate: Int? = null + private var hrr60: Int? = null + private var hrr300: Int? = null + private var cooldownStartTimestampMs: Long? = null + private var lastBeatMonotonicMs: Long? = null + private var lastHeartRateMonotonicMs: Long? = null + private var frozenSessionSummary: PolarSessionSummary? = null + private var rawEcgStartMs: Long? = null + private var rawEcgSampleCount = 0 + private val persistenceMutex = Mutex() + private data class PendingTelemetry( + val sessionId: String, + val beats: List = emptyList(), + val ecgs: List = emptyList(), + val recovery: PolarSessionSummary? = null, + ) + private val pendingTelemetry = mutableListOf() + private data class DetachedRecording( + val beats: List, + val ecgs: List, + val summary: PolarSessionSummary?, + ) + private val detachedRecordings = mutableMapOf() + private val _telemetryPersistenceError = MutableStateFlow(null) + val telemetryPersistenceError: StateFlow = _telemetryPersistenceError.asStateFlow() + + init { + if (telemetryRepository != null) scope.launch(persistenceDispatcher) { + persistenceMutex.withLock { + try { + telemetryRepository.pruneRawEcg() + } catch (error: Exception) { + error.rethrowIfCancellation() + _telemetryPersistenceError.value = error.message + log.e(error) { "Failed to prune raw Polar ECG" } + } + } + } + } + + private val _isCooldownActive = MutableStateFlow(false) + val isCooldownActive: StateFlow = _isCooldownActive.asStateFlow() + + private val _cooldownSecondsRemaining = MutableStateFlow(0) + val cooldownSecondsRemaining: StateFlow = _cooldownSecondsRemaining.asStateFlow() + + private val _cooldownHrr60 = MutableStateFlow(null) + val cooldownHrr60: StateFlow = _cooldownHrr60.asStateFlow() + + private val _cooldownHrr300 = MutableStateFlow(null) + val cooldownHrr300: StateFlow = _cooldownHrr300.asStateFlow() + + private val _cooldownStartHeartRate = MutableStateFlow(null) + val cooldownStartHeartRate: StateFlow = _cooldownStartHeartRate.asStateFlow() + + private val _activeCooldownSessionId = MutableStateFlow(null) + val activeCooldownSessionId: StateFlow = _activeCooldownSessionId.asStateFlow() + + private val _lastRecordedSessionId = MutableStateFlow(null) + val lastRecordedSessionId: StateFlow = _lastRecordedSessionId.asStateFlow() + + private val _latestSessionSummary = MutableStateFlow(null) + val latestSessionSummary: StateFlow = _latestSessionSummary.asStateFlow() + + private val completedCooldownSessionIds = mutableSetOf() + private var completedCooldownSessionId: String? = null + private val committedSessionIds = mutableSetOf() + private data class CooldownRequest(val sourceId: String, val forceRestart: Boolean) + private var pendingCooldownRequest: CooldownRequest? = null + + val currentRecordingSessionId: String? get() = sessionLock.withLock { activeSessionId } + + fun startSessionRecording(sessionId: String, phase: String = "WARMUP"): Unit = sessionLock.withLock { + if (_isCooldownActive.value) stopCooldown() + pendingCooldownRequest = null + committedSessionIds.remove(sessionId) + recordedSessionId?.let { previousId -> + if (activeSessionId != null || bufferedBeatSamples.isNotEmpty() || bufferedEcgSegments.isNotEmpty()) { + detachedRecordings[previousId] = DetachedRecording( + bufferedBeatSamples.toList(), bufferedEcgSegments.toList(), getSessionSummary(previousId), + ) + } + } + detachedRecordings.remove(sessionId) + cooldownJob?.cancel() + cooldownJob = null + cooldownStartTimestampMs = null + _isCooldownActive.value = false + _cooldownSecondsRemaining.value = 0 + _cooldownHrr60.value = null + _cooldownHrr300.value = null + _cooldownStartHeartRate.value = null + _activeCooldownSessionId.value = null + completedCooldownSessionIds.remove(sessionId) + completedCooldownSessionId = null + activeSessionId = sessionId + recordedSessionId = sessionId + _lastRecordedSessionId.value = sessionId + currentPhase = phase + sessionLock.withLock { + bufferedBeatSamples.clear() + bufferedEcgSegments.clear() + sessionHrs.clear() + sessionRrs.clear() + recentRrs.clear() + maxTorsoPitch = null + maxTorsoRoll = null + frozenSessionSummary = null + rawEcgStartMs = null + rawEcgSampleCount = 0 + } + lastBeatMonotonicMs = null + lastHeartRateMonotonicMs = null + endHeartRate = null + hrr60 = null + hrr300 = null + _latestSessionSummary.value = null + + if (_connectionState.value is ConnectionState.Connected) { + scope.launch { ensureRecordingTelemetryStreams() } + } else if (_rememberedAddress.value != null && _isAutoReconnectEnabled.value) { + log.i { "Workout recording started without active Polar connection; triggering auto-reconnect" } + ensureConnectedOrReconnect() + } + } + + fun freezeSessionRecording(sessionId: String): PolarSessionSummary? = sessionLock.withLock { + if (sessionId != recordedSessionId) return detachedRecordings[sessionId]?.summary + if (frozenSessionSummary == null) frozenSessionSummary = getSessionSummary(sessionId) + if (!_isCooldownActive.value) activeSessionId = null + frozenSessionSummary + } + + /** + * Flush buffered telemetry records once the parent row is committed. + * The returned job finishes after the queued IO transaction. + */ + fun flushSessionTelemetry(sessionId: String, recordedSourceId: String = sessionId): Job? { + var deferredCooldown: CooldownRequest? = null + val (beatsToFlush, ecgsToFlush) = sessionLock.withLock { + if (recordedSourceId != recordedSessionId) { + val detached = detachedRecordings.remove(recordedSourceId) ?: return null + committedSessionIds.add(sessionId) + if (pendingCooldownRequest?.sourceId == recordedSourceId) pendingCooldownRequest = null + return queueTelemetry(PendingTelemetry(sessionId, detached.beats, detached.ecgs)) + } + committedSessionIds.add(sessionId) + deferredCooldown = pendingCooldownRequest?.takeIf { it.sourceId == recordedSourceId } + pendingCooldownRequest = null + frozenSessionSummary = (frozenSessionSummary ?: getSessionSummary(recordedSourceId))?.copy(sessionId = sessionId) + if (!_isCooldownActive.value) { + activeSessionId = null + } else { + activeSessionId = sessionId + _activeCooldownSessionId.value = sessionId + } + recordedSessionId = sessionId + _lastRecordedSessionId.value = sessionId + val beats = bufferedBeatSamples.toList() + bufferedBeatSamples.clear() + val ecgs = bufferedEcgSegments.toList() + bufferedEcgSegments.clear() + Pair(beats, ecgs) + } + _latestSessionSummary.value = getSessionSummary(sessionId) + + val flushJob = queueTelemetry(PendingTelemetry(sessionId, beatsToFlush, ecgsToFlush)) + deferredCooldown?.let { triggerCooldown(sessionId, it.forceRestart) } + return flushJob + } + + private fun queueTelemetry(batch: PendingTelemetry): Job? { + val repository = telemetryRepository ?: return null + sessionLock.withLock { pendingTelemetry.add(batch) } + return scope.launch(persistenceDispatcher) { + persistenceMutex.withLock { + while (true) { + val pending = sessionLock.withLock { pendingTelemetry.firstOrNull() } ?: break + try { + repository.saveTelemetry( + sessionId = pending.sessionId, + beats = pending.beats.map { beat -> + PolarBeatSample( + id = 0, + sessionId = pending.sessionId, + timestampMs = beat.timestampMs, + phase = beat.phase, + hrBpm = beat.hrBpm, + rrMs = beat.rrMs, + rmssdMs = beat.rmssdMs, + torsoPitchDeg = beat.torsoPitchDeg, + torsoRollDeg = beat.torsoRollDeg, + dynamicAccMg = beat.dynamicAccMg, + ) + }, + ecgs = pending.ecgs.map { segment -> + PolarEcgSegment( + id = 0, + sessionId = pending.sessionId, + sensorTimestampNs = segment.sensorTimestampNs, + wallClockTimestampMs = segment.wallClockTimestampMs, + phase = segment.phase, + samplesUvCsv = segment.samplesUvCsv, + ) + }, + recovery = pending.recovery?.let { PolarRecovery(it.hrr60Bpm, it.hrr300Bpm) }, + ) + sessionLock.withLock { pendingTelemetry.remove(pending) } + _telemetryPersistenceError.value = null + } catch (error: Exception) { + error.rethrowIfCancellation() + _telemetryPersistenceError.value = error.message ?: "Polar telemetry could not be saved" + log.e(error) { "Failed to flush Polar telemetry for ${pending.sessionId}" } + break + } + } + } + } + } + + /** + * Cleanly discards active recording buffers and resets session tracking if a set is cancelled. + * If [activeSessionId] is already null (meaning the set recording was committed/flushed or ended), + * this is a strict no-op so it never wipes [_lastRecordedSessionId], [sessionHrs], or a post-workout cooldown. + */ + fun discardActiveRecording(): Unit = sessionLock.withLock { + val targetSessionId = activeSessionId ?: return + if (_isCooldownActive.value) return + sessionLock.withLock { + bufferedBeatSamples.clear() + bufferedEcgSegments.clear() + sessionHrs.clear() + sessionRrs.clear() + recentRrs.clear() + maxTorsoPitch = null + maxTorsoRoll = null + } + if (_lastRecordedSessionId.value == targetSessionId) { + _lastRecordedSessionId.value = null + } + lastBeatMonotonicMs = null + activeSessionId = null + recordedSessionId = null + completedCooldownSessionId = null + currentPhase = "WARMUP" + _latestSessionSummary.value = null + } + + internal fun getBufferedBeatSamplesForTesting(): List = + sessionLock.withLock { bufferedBeatSamples.toList() } + + internal fun getBufferedEcgSegmentsForTesting(): List = + sessionLock.withLock { bufferedEcgSegments.toList() } + + fun updateSessionPhase(phase: String): Unit = sessionLock.withLock { + if (phase == "WORKING_SET" || phase == "WARMUP") { + if (_isCooldownActive.value || currentPhase == "POST_WORKOUT_COOLDOWN") { + stopCooldown() + } + } + currentPhase = phase + } + + fun cancelCooldown() { + sessionLock.withLock { pendingCooldownRequest = null } + if (_isCooldownActive.value) stopCooldown() + } + + fun triggerCooldown(sessionId: String? = null, forceRestart: Boolean = false): Unit = sessionLock.withLock { + if (!endSessionRecording(sessionId, forceRestart = forceRestart)) return + if (_connectionState.value is ConnectionState.Connected) { + scope.launch { ensureRecordingTelemetryStreams() } + } + } + + fun stopCooldown(): Unit = sessionLock.withLock { + if (!_isCooldownActive.value) return + val targetSessionId = activeSessionId ?: _activeCooldownSessionId.value + cooldownJob?.cancel() + cooldownJob = null + val elapsedMs = cooldownStartTimestampMs?.let { monotonicClock() - it } ?: 0L + cooldownStartTimestampMs = null + val finalHr = _hrMeasurement.value?.hr?.takeIf { + lastHeartRateMonotonicMs?.let { timestamp -> monotonicClock() - timestamp <= 3000L } == true + } + val endHr = endHeartRate ?: _cooldownStartHeartRate.value + if (finalHr != null && endHr != null) { + val drop = (endHr - finalHr).coerceAtLeast(0) + if (hrr60 == null && elapsedMs >= 60_000L) { + hrr60 = drop + _cooldownHrr60.value = drop + } + if (hrr300 == null && elapsedMs >= 300_000L) { + hrr300 = drop + _cooldownHrr300.value = drop + } + } + _isCooldownActive.value = false + _cooldownSecondsRemaining.value = 0 + if (targetSessionId != null) { + persistPolarSummary(targetSessionId) + _latestSessionSummary.value = getSessionSummary(targetSessionId) + completedCooldownSessionIds.add(targetSessionId) + completedCooldownSessionId = targetSessionId + flushSessionTelemetry(targetSessionId) + } + activeSessionId = null + _activeCooldownSessionId.value = null + scope.launch { stopUnusedTelemetryStreams() } + } + + fun endSessionRecording(sessionId: String? = null, forceRestart: Boolean = false): Boolean = sessionLock.withLock { + val knownRecordedId = _lastRecordedSessionId.value ?: recordedSessionId ?: completedCooldownSessionId + val targetSessionId: String = sessionId ?: knownRecordedId ?: return false + if (targetSessionId != knownRecordedId) return false + if (telemetryRepository != null && targetSessionId !in committedSessionIds) { + pendingCooldownRequest = CooldownRequest(targetSessionId, forceRestart) + return false + } + if (!forceRestart && completedCooldownSessionIds.contains(targetSessionId)) { + Logger.d { "endSessionRecording: Cooldown already completed for session $targetSessionId; skipping" } + return false + } + if (!forceRestart && _isCooldownActive.value && _activeCooldownSessionId.value == targetSessionId) { + Logger.d { "endSessionRecording: Cooldown already active for session $targetSessionId; skipping restart" } + return false + } + if (forceRestart) { + if (_isCooldownActive.value) stopCooldown() + completedCooldownSessionIds.remove(targetSessionId) + } + sessionLock.withLock { + if (frozenSessionSummary == null) frozenSessionSummary = getSessionSummary(targetSessionId) + } + recordedSessionId = targetSessionId + activeSessionId = targetSessionId + _lastRecordedSessionId.value = targetSessionId + _activeCooldownSessionId.value = targetSessionId + val lastHr = sessionLock.withLock { sessionHrs.lastOrNull() } + endHeartRate = _hrMeasurement.value?.hr ?: lastHr + _cooldownStartHeartRate.value = endHeartRate + currentPhase = "POST_WORKOUT_COOLDOWN" + _isCooldownActive.value = true + _cooldownSecondsRemaining.value = 300 + _cooldownHrr60.value = null + _cooldownHrr300.value = null + hrr60 = null + hrr300 = null + _latestSessionSummary.value = getSessionSummary(targetSessionId) + + cooldownJob?.cancel() + val cooldownStartMs = monotonicClock() + cooldownStartTimestampMs = cooldownStartMs + cooldownJob = scope.launch { + while (isActive) { + delay(1000L) + sessionLock.withLock { + if (!_isCooldownActive.value || cooldownStartTimestampMs != cooldownStartMs) return@launch + if (peripheral != null && !_isConnected.value) { + cancelCooldown() + return@launch + } + checkPacketAbsenceTimeout(monotonicClock()) + if (!_isCooldownActive.value) return@launch + val elapsedMs = monotonicClock() - cooldownStartMs + _cooldownSecondsRemaining.value = (300 - elapsedMs / 1000).toInt().coerceAtLeast(0) + val currentHr = _hrMeasurement.value?.hr + val startingHr = endHeartRate + if (elapsedMs >= 60_000L && hrr60 == null && currentHr != null && startingHr != null) { + hrr60 = (startingHr - currentHr).coerceAtLeast(0) + _cooldownHrr60.value = hrr60 + persistPolarSummary(targetSessionId) + } + _latestSessionSummary.value = getSessionSummary(targetSessionId) + if (elapsedMs >= 300_000L) { + stopCooldown() + return@launch + } + } + } + } + true + } + + private data class SessionSampleSnapshot( + val hrs: List, + val rrs: List, + val maxPitch: Double?, + val rollMax: Double?, + ) + + fun getSessionSummary(sessionId: String): PolarSessionSummary? = sessionLock.withLock { + detachedRecordings[sessionId]?.let { return it.summary } + sessionLock.withLock { + frozenSessionSummary?.takeIf { it.sessionId == sessionId }?.let { + return it.copy(hrr60Bpm = hrr60, hrr300Bpm = hrr300) + } + } + if (sessionId != recordedSessionId) return null + val snapshot = sessionLock.withLock { + SessionSampleSnapshot( + hrs = sessionHrs.toList(), + rrs = sessionRrs.toList(), + maxPitch = maxTorsoPitch, + rollMax = maxTorsoRoll, + ) + } + val avgHr = if (snapshot.hrs.isNotEmpty()) snapshot.hrs.average().roundToInt() else null + val maxHr = snapshot.hrs.maxOrNull() + val minHr = snapshot.hrs.minOrNull() + val validRrs = snapshot.rrs.filter { it in 250.0..2500.0 } + val avgRr = if (validRrs.isNotEmpty()) validRrs.average().toFloat() else null + val rmssd = PolarTelemetryUtils.calculateRmssdMs(snapshot.rrs)?.toFloat() + return PolarSessionSummary( + sessionId = sessionId, + avgHeartRateBpm = avgHr, + maxHeartRateBpm = maxHr, + minHeartRateBpm = minHr, + avgRrIntervalMs = avgRr, + rmssdMs = rmssd, + hrr60Bpm = hrr60, + hrr300Bpm = hrr300, + maxTorsoPitchDeg = snapshot.maxPitch?.toFloat(), + maxTorsoRollDeg = snapshot.rollMax?.toFloat(), + ) + } + + private fun persistPolarSummary(sessionId: String) { + val summary = getSessionSummary(sessionId) ?: return + queueTelemetry(PendingTelemetry(sessionId, recovery = summary)) + } + + // ===== Connection & Discovery State ===== + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + internal fun setConnectedForTesting(connected: Boolean) { + _isConnected.value = connected + if (connected) { + wasEverConnected = true + isExplicitDisconnect = false + _connectionState.value = ConnectionState.Connected("Polar H10", "00:11:22:33:44:55") + } else { + wasEverConnected = false + _connectionState.value = ConnectionState.Disconnected + cleanupSessionTelemetry() + } + } + + private val _scannedDevices = MutableStateFlow>(emptyList()) + val scannedDevices: StateFlow> = _scannedDevices.asStateFlow() + + // ===== Telemetry State & Streams ===== + private val _isContactDetected = MutableStateFlow(false) + val isContactDetected: StateFlow = _isContactDetected.asStateFlow() + + fun isBatteryCritical(): Boolean = + _batteryLevel.value != null && _batteryLevel.value!! <= CRITICAL_BATTERY_PERCENTAGE + + internal fun setContactDetectedForTesting(detected: Boolean) { + _isContactDetected.value = detected + } + + internal fun setBatteryLevelForTesting(level: Int?) { + _batteryLevel.value = level + if (isBatteryCritical()) { + _isContactDetected.value = false + clearCachedTelemetry() + if (_isCooldownActive.value) { + cancelCooldown() + } + } + } + + internal fun setEcgStreamingForTesting(streaming: Boolean) { + _isEcgStreaming.value = streaming + if (!streaming) { + _latestEcg.value = null + checkAndTeardownPmdObservers() + } + } + + internal fun setAccStreamingForTesting(streaming: Boolean) { + _isAccStreaming.value = streaming + if (!streaming) { + _latestAcc.value = null + checkAndTeardownPmdObservers() + } + } + + private val _batteryLevel = MutableStateFlow(null) + val batteryLevel: StateFlow = _batteryLevel.asStateFlow() + + private val _hrMeasurement = MutableStateFlow(null) + val hrMeasurement: StateFlow = _hrMeasurement.asStateFlow() + + private val _ecgStream = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 128, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val ecgStream: SharedFlow = _ecgStream.asSharedFlow() + + private val _accStream = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val accStream: SharedFlow = _accStream.asSharedFlow() + + private val _latestEcg = MutableStateFlow(null) + val latestEcg: StateFlow = _latestEcg.asStateFlow() + + private val _latestAcc = MutableStateFlow(null) + val latestAcc: StateFlow = _latestAcc.asStateFlow() + + private val _rrBeats = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val rrBeats: SharedFlow = _rrBeats.asSharedFlow() + + // ===== Internal Connection References ===== + @Volatile + private var peripheral: Peripheral? = null + + @Volatile + private var wasEverConnected = false + + @Volatile + private var isExplicitDisconnect = false + + internal val wasEverConnectedForTesting: Boolean get() = wasEverConnected + internal val isExplicitDisconnectForTesting: Boolean get() = isExplicitDisconnect + internal val isReconnectJobActiveForTesting: Boolean get() = reconnectJob?.isActive == true + + private var scanJob: Job? = null + + @Volatile + private var reconnectJob: Job? = null + private val advertisementLock = reentrantLock() + private val discoveredAdvertisements = mutableMapOf() + + private fun cachedAdvertisement(address: String): Advertisement? = advertisementLock.withLock { + discoveredAdvertisements[address] + ?: discoveredAdvertisements.values.firstOrNull { it.identifier.toString().equals(address, true) } + } + + private var stateObserverJob: Job? = null + private var hrObserveJob: Job? = null + private var batteryObserveJob: Job? = null + + // ===== PMD Stream Control ===== + private val pmdMutex = Mutex() + private val pmdCommandMutex = Mutex() + private val telemetryLifecycleMutex = Mutex() + + private suspend fun ensureRecordingTelemetryStreams() = telemetryLifecycleMutex.withLock { + if (!sessionLock.withLock { activeSessionId != null || _isCooldownActive.value }) return@withLock + if (pmdControlPointJob?.isActive != true || pmdDataJob?.isActive != true) { + _isEcgStreaming.value = false + _isAccStreaming.value = false + } + if (!_isEcgStreaming.value) startEcgStream() + if (!_isAccStreaming.value) startAccStream() + } + + private suspend fun stopUnusedTelemetryStreams() = telemetryLifecycleMutex.withLock { + if (sessionLock.withLock { activeSessionId != null || _isCooldownActive.value }) return@withLock + stopEcgStream() + stopAccStream() + } + private val pmdCpResponses = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private var pmdControlPointJob: Job? = null + private var pmdDataJob: Job? = null + + private val _isEcgStreaming = MutableStateFlow(false) + val isEcgStreaming: StateFlow = _isEcgStreaming.asStateFlow() + + private val _isAccStreaming = MutableStateFlow(false) + val isAccStreaming: StateFlow = _isAccStreaming.asStateFlow() + + private var previousContactDetected: Boolean? = null + private var hasReceivedFirstEcgPacket = false + private var hasReceivedFirstAccPacket = false + + private fun checkAndTeardownPmdObservers() { + if (!_isEcgStreaming.value && !_isAccStreaming.value) { + pmdControlPointJob?.cancel() + pmdControlPointJob = null + pmdDataJob?.cancel() + pmdDataJob = null + } + } + + // ===== Chained RR Clock State ===== + private var lastBeatTimestampMs: Long? = null + + /** + * Start scanning for Polar H10 devices (by name prefix or Heart Rate 0x180D UUID). + * Automatically stops when [BleConstants.SCAN_TIMEOUT_MS] elapses. + */ + suspend fun startScanning(): Result { + return try { + log.i { "Starting BLE scan for Polar H10 devices" } + logRepo.info( + eventType = LogEventType.SCAN_START, + message = "[HRM] Scanning for Polar H10 devices", + tag = "HRM", + ) + reconnectJob?.cancel() + reconnectJob = null + scanJob?.cancel() + scanJob = null + _scannedDevices.value = emptyList() + advertisementLock.withLock { discoveredAdvertisements.clear() } + + _connectionState.value = ConnectionState.Scanning + + var currentDevices = emptyList() + + scanJob = scope.launch { + try { + withTimeoutOrNull(BleConstants.SCAN_TIMEOUT_MS) { + advertisements() + .filter { adv -> isPolarH10Advertisement(adv) } + .collect { adv -> + val identifier = adv.identifier.toString() + advertisementLock.withLock { discoveredAdvertisements[identifier] = adv } + val name = adv.name ?: "Polar H10" + val scanned = ScannedDevice(name = name, address = identifier, rssi = adv.rssi) + + val updated = currentDevices.toMutableList() + val existingIndex = updated.indexOfFirst { it.address == identifier } + if (existingIndex >= 0) { + updated[existingIndex] = scanned + } else { + updated.add(scanned) + } + val sorted = updated.sortedByDescending { it.rssi } + currentDevices = sorted + _scannedDevices.value = sorted + } + } + if (_connectionState.value is ConnectionState.Scanning) { + log.i { "Polar scan timeout reached (${BleConstants.SCAN_TIMEOUT_MS}ms)" } + logRepo.info( + eventType = LogEventType.SCAN_STOP, + message = "[HRM] Polar H10 scan timeout reached (${BleConstants.SCAN_TIMEOUT_MS}ms)", + tag = "HRM", + ) + _connectionState.value = ConnectionState.Disconnected + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.e(e) { "Scan error: ${e.message}" } + if (_connectionState.value is ConnectionState.Scanning) { + _connectionState.value = ConnectionState.Disconnected + } + } + } + Result.success(Unit) + } catch (e: Exception) { + e.rethrowIfCancellation() + log.e(e) { "Failed to start Polar scan: ${e.message}" } + Result.failure(e) + } + } + + suspend fun stopScanning() { + scanJob?.cancel() + scanJob = null + if (_connectionState.value is ConnectionState.Scanning) { + logRepo.info( + eventType = LogEventType.SCAN_STOP, + message = "[HRM] Polar H10 scan stopped", + tag = "HRM", + ) + _connectionState.value = ConnectionState.Disconnected + } + } + + private class AutoReconnectMatchFound(val device: ScannedDevice) : CancellationException() + + /** + * Triggers an immediate auto-reconnect attempt unless explicitly disconnected + * to a remembered Polar sensor if not already connecting or reconnecting. + */ + fun ensureConnectedOrReconnect() { + if (isExplicitDisconnect) return + if (_connectionState.value is ConnectionState.Connected || _connectionState.value is ConnectionState.Connecting) { + return + } + if (reconnectJob?.isActive == true) { + return + } + reconnectJob = scope.launch { autoReconnectIfRemembered() } + .also { job -> job.invokeOnCompletion { if (reconnectJob === job) reconnectJob = null } } + } + + /** + * Auto-reconnect to the remembered Polar H10 if one was previously connected + * and auto-reconnect is enabled. + */ + suspend fun autoReconnectIfRemembered(timeoutMs: Long = 10000L): Result { + if (_connectionState.value is ConnectionState.Connected || _connectionState.value is ConnectionState.Connecting) { + return Result.success(Unit) + } + if (!_isAutoReconnectEnabled.value) { + return Result.success(Unit) + } + if (isExplicitDisconnect) { + log.d { "Auto-reconnect suppressed due to explicit disconnect" } + return Result.success(Unit) + } + val address = _rememberedAddress.value ?: return Result.success(Unit) + val name = _rememberedName.value ?: "Polar H10" + log.i { "Attempting auto-reconnect to remembered Polar H10: $name ($address)" } + + val cachedAdv = cachedAdvertisement(address) + if (cachedAdv != null) { + val connectResult = connect(ScannedDevice(name = name, address = address)) + if (connectResult.isSuccess) { + return connectResult + } + log.w { "Direct connection with cached advertisement failed, falling back to targeted scan for $name ($address)" } + } + + return try { + val matchedDevice = withTimeoutOrNull(timeoutMs) { + var found: ScannedDevice? = null + try { + advertisements() + .filter { isPolarH10Advertisement(it) } + .collect { adv -> + val id = adv.identifier.toString() + advertisementLock.withLock { discoveredAdvertisements[id] = adv } + if (id.equals(address, ignoreCase = true) || (adv.name != null && adv.name == name)) { + found = ScannedDevice(name = adv.name ?: name, address = id, rssi = adv.rssi) + throw AutoReconnectMatchFound(found!!) + } + } + } catch (e: AutoReconnectMatchFound) { + found = e.device + } + found + } + + if (matchedDevice != null) { + connect(matchedDevice) + } else { + log.w { "Timed out waiting for remembered Polar H10 ($address)" } + Result.failure(IllegalStateException("Timed out waiting for remembered Polar H10")) + } + } catch (e: Exception) { + e.rethrowIfCancellation() + log.w { "Auto-reconnect to Polar H10 failed: ${e.message}" } + Result.failure(e) + } + } + + /** + * Connect to the selected Polar H10 device. + */ + suspend fun connect(device: ScannedDevice): Result { + log.i { "Connecting to Polar H10: ${device.name} (${device.address})" } + val currentJob = currentCoroutineContext()[Job] + if (reconnectJob !== currentJob) { + reconnectJob?.cancel() + } + + val wasScanning = scanJob?.isActive == true || _connectionState.value is ConnectionState.Scanning + stopScanning() + if (wasScanning) { + delay(150) + } + + val advertisement = cachedAdvertisement(device.address) + ?: return Result.failure(IllegalStateException("Polar device ${device.address} not found in scanned cache")) + + logRepo.info( + eventType = LogEventType.CONNECT_START, + message = "[HRM] Connecting to Polar H10: ${device.name}", + deviceName = device.name, + deviceAddress = device.address, + tag = "HRM", + ) + + return try { + cleanupExistingConnection() + _connectionState.value = ConnectionState.Connecting + isExplicitDisconnect = false + wasEverConnected = false + + val p = createPeripheral(advertisement) + peripheral = p + + stateObserverJob = scope.launch { + p.state.collect { state -> + if (peripheral !== p) { + log.d { "Ignoring state change ($state) for stale peripheral $p" } + return@collect + } + log.d { "Polar H10 state: $state" } + when (state) { + is State.Connecting -> { + _isConnected.value = false + _connectionState.value = ConnectionState.Connecting + } + is State.Connected -> { + wasEverConnected = true + _isConnected.value = true + _connectionState.value = ConnectionState.Connected( + deviceName = device.name, + deviceAddress = device.address, + ) + logRepo.info( + eventType = LogEventType.CONNECT_SUCCESS, + message = "[HRM] Connected to Polar H10: ${device.name}", + deviceName = device.name, + deviceAddress = device.address, + tag = "HRM", + ) + if (_isAutoReconnectEnabled.value) { + settings?.putString(KEY_POLAR_REMEMBERED_ADDRESS, device.address) + settings?.putString(KEY_POLAR_REMEMBERED_NAME, device.name) + _rememberedAddress.value = device.address + _rememberedName.value = device.name + } + onDeviceConnected(p) + } + is State.Disconnecting -> { + _isConnected.value = false + } + is State.Disconnected -> { + if (!wasEverConnected) { + log.d { "Polar H10 initial state: Disconnected (awaiting connect() call)" } + return@collect + } + wasEverConnected = false + _isConnected.value = false + _connectionState.value = ConnectionState.Disconnected + logRepo.info( + eventType = LogEventType.DISCONNECT, + message = "[HRM] Polar H10 disconnected", + deviceName = device.name, + deviceAddress = device.address, + tag = "HRM", + ) + cleanupSessionTelemetry() + } + } + } + } + + val connected = withTimeoutOrNull(BleConstants.CONNECTION_TIMEOUT_MS) { + p.connect() + p.state.first { it is State.Connected } + } + check(connected != null) { "Polar H10 connection timed out" } + + Result.success(Unit) + } catch (e: Exception) { + withContext(NonCancellable) { + _isConnected.value = false + cleanupExistingConnection() + _connectionState.value = ConnectionState.Disconnected + } + e.rethrowIfCancellation() + log.e(e) { "Failed to connect to Polar H10: ${e.message}" } + logRepo.error( + eventType = LogEventType.CONNECT_FAIL, + message = "[HRM] Failed to connect: ${e.message}", + deviceName = device.name, + deviceAddress = device.address, + details = e.stackTraceToString(), + tag = "HRM", + ) + _connectionState.value = ConnectionState.Error("Connection failed: ${e.message}", e) + Result.failure(e) + } + } + + private suspend fun onDeviceConnected(p: Peripheral) { + log.i { "Configuring Polar H10 connection parameters and services" } + startWatchdog() + + // 1. Request MTU 232 if supported + try { + val mtu = p.requestMtuIfSupported(BleConstants.Polar.DESIRED_PMD_MTU) + log.i { "Polar H10 MTU negotiated: $mtu (desired: ${BleConstants.Polar.DESIRED_PMD_MTU})" } + } catch (e: Exception) { + e.rethrowIfCancellation() + log.w { "MTU negotiation error: ${e.message}" } + } + + // 2. Read initial battery level (0x2A19) + try { + val batteryBytes = p.read(BleConstants.Polar.batteryLevelCharacteristic) + val level = parsePolarBatteryLevel(batteryBytes) + _batteryLevel.value = level + log.i { "Polar H10 battery level: $level%" } + } catch (e: Exception) { + e.rethrowIfCancellation() + log.w { "Could not read Polar battery level: ${e.message}" } + } + + batteryObserveJob?.cancel() + batteryObserveJob = scope.launch { + try { + p.observe(BleConstants.Polar.batteryLevelCharacteristic) + .catch { e -> + e.rethrowIfCancellation() + log.d { "Battery notification non-fatal: ${e.message}" } + } + .collect { bytes -> + if (p.state.value !is State.Connected) return@collect + parsePolarBatteryLevel(bytes)?.let { level -> + _batteryLevel.value = level + if (level <= CRITICAL_BATTERY_PERCENTAGE) { + log.w { "Polar H10 battery critical ($level%). Dropping telemetry." } + _isContactDetected.value = false + clearCachedTelemetry() + if (_isCooldownActive.value) { + cancelCooldown() + } + } + } + } + } catch (e: Exception) { + e.rethrowIfCancellation() + } + } + + // 3. Subscribe to HR/RR notifications (0x2A37) + startHrObservation(p) + + // 4. Pre-subscribe to PMD Control Point & Data so observers are active and shared + try { + ensurePmdSubscribed(p) + } catch (e: Exception) { + e.rethrowIfCancellation() + log.w { "Pre-subscribing PMD non-fatal (will retry on stream start): ${e.message}" } + } + + if (activeSessionId != null) { + scope.launch { ensureRecordingTelemetryStreams() } + } + } + + private fun startHrObservation(p: Peripheral) { + hrObserveJob?.cancel() + hrObserveJob = scope.launch { + try { + log.i { "Subscribing to Polar HR & RR notifications (0x2A37)" } + logRepo.info( + eventType = LogEventType.NOTIFICATION, + message = "[HRM] Subscribed to Polar HR & RR notifications (0x2A37)", + tag = "HRM", + ) + p.observe(BleConstants.Polar.hrMeasurementCharacteristic) + .catch { e -> + e.rethrowIfCancellation() + log.e { "Polar HR observation error: ${e.message}" } + } + .collect { data -> + if (p.state.value !is State.Connected) return@collect + val measurement = parsePolarHrMeasurement(data) ?: return@collect + if (!measurement.contactDetected || measurement.hr <= 0 || isBatteryCritical()) { + log.w { "Polar H10 strap removed, zero HR, or critical battery (contact=${measurement.contactDetected}, hr=${measurement.hr}, batt=${_batteryLevel.value}). Dropping telemetry." } + handleNoContact(measurement) + return@collect + } + + if (previousContactDetected != true) { + previousContactDetected = true + logRepo.debug( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] Contact detected (contact: false -> true, hr=${measurement.hr})", + tag = "HRM", + ) + } + + _isContactDetected.value = true + contactLossJob?.cancel() + contactLossJob = null + lastValidPacketTimestampMs = monotonicClock() + _hrMeasurement.value = measurement + + processRrIntervals(measurement) + } + } catch (e: Exception) { + e.rethrowIfCancellation() + log.e(e) { "Failed to maintain Polar HR observation: ${e.message}" } + } + } + } + + private fun handleNoContact(measurement: PolarHrMeasurement): Unit = sessionLock.withLock { + val hadContact = previousContactDetected == true + if (previousContactDetected != false) { + previousContactDetected = false + if (!measurement.contactDetected) { + logRepo.warning( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] Poor skin contact detected (contact=${measurement.contactDetected}, hr=${measurement.hr})", + tag = "HRM", + ) + } + if (hadContact) { + logRepo.debug( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] Contact lost (contact: true -> false)", + tag = "HRM", + ) + } + } + _isContactDetected.value = false + clearCachedTelemetry() + if (_isCooldownActive.value && contactLossJob?.isActive != true) contactLossJob = scope.launch { + delay(CONTACT_LOSS_DEBOUNCE_MS) + if (!_isContactDetected.value) cancelCooldown() + } + } + + /** + * Reconstructs accurate R-wave beat timestamps by chaining RR intervals together, + * eliminating packet jitter across connection intervals. Automatically re-anchors + * if gaps or drift exceed 5 seconds. + */ + internal fun processRrIntervals(measurement: PolarHrMeasurement): Unit = sessionLock.withLock { + if (peripheral != null && peripheral?.state?.value !is State.Connected) { + return + } + if (!measurement.contactDetected || measurement.hr <= 0 || isBatteryCritical()) { + handleNoContact(measurement) + return + } + if (previousContactDetected != true) { + previousContactDetected = true + logRepo.debug( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] Contact detected (contact: false -> true, hr=${measurement.hr})", + tag = "HRM", + ) + } + _isContactDetected.value = true + contactLossJob?.cancel() + contactLossJob = null + lastValidPacketTimestampMs = monotonicClock() + lastHeartRateMonotonicMs = monotonicClock() + _hrMeasurement.value = measurement + val rrs = measurement.rrIntervalsMs + + val nowMonoMs = monotonicClock() + val nowWallMs = wallClock() + if (_isCooldownActive.value) { + val startTs = cooldownStartTimestampMs + if (startTs != null && (nowMonoMs - startTs) <= 30_000L) { + val peak = maxOf(endHeartRate ?: measurement.hr, measurement.hr) + if (endHeartRate != peak) { + endHeartRate = peak + _cooldownStartHeartRate.value = peak + } + } + } + + if (activeSessionId != null && frozenSessionSummary == null) sessionHrs.add(measurement.hr) + if (rrs.isEmpty()) return + val previousBeat = lastBeatTimestampMs + val previousBeatMono = lastBeatMonotonicMs + + val startingTs: Long = if (previousBeat == null || previousBeatMono == null || (nowMonoMs - previousBeatMono) > 5000L || (nowMonoMs - previousBeatMono) < -5000L) { + val totalRrDurationMs = rrs.sum().toLong() + nowWallMs - totalRrDurationMs + } else { + previousBeat + } + + var runningTs = startingTs + for (rr in rrs) { + runningTs += rr.toLong() + val beat = PolarRrBeat( + rrIntervalMs = rr, + timestampMs = runningTs, + hr = measurement.hr, + ) + _rrBeats.tryEmit(beat) + + val currentSid = activeSessionId + if (currentSid != null) { + val currentRmssd = sessionLock.withLock { + if (frozenSessionSummary == null) { + sessionRrs.add(beat.rrIntervalMs) + } + recentRrs.addLast(beat.rrIntervalMs) + if (recentRrs.size > 30) recentRrs.removeFirst() + PolarTelemetryUtils.calculateRmssdMs(recentRrs.toList()) + } + sessionLock.withLock { + bufferedBeatSamples.add( + BufferedBeatSample( + timestampMs = beat.timestampMs, + phase = currentPhase, + hrBpm = beat.hr.toLong(), + rrMs = beat.rrIntervalMs, + rmssdMs = currentRmssd, + torsoPitchDeg = _torsoPitchDeg.value, + torsoRollDeg = _torsoRollDeg.value, + dynamicAccMg = _dynamicAccMg.value?.toLong(), + ) + ) + } + } + } + + lastBeatTimestampMs = runningTs + lastBeatMonotonicMs = nowMonoMs + } + + private suspend fun ensurePmdSubscribed(p: Peripheral): Boolean = pmdMutex.withLock { + if (pmdControlPointJob?.isActive == true && pmdDataJob?.isActive == true) { + return true + } + + pmdControlPointJob?.cancel() + pmdDataJob?.cancel() + + val cpSubscribed = CompletableDeferred() + val dataSubscribed = CompletableDeferred() + + // 1. Subscribe to PMD Control Point (FB005C81) + pmdControlPointJob = scope.launch { + try { + p.observe(BleConstants.Polar.pmdControlPointCharacteristic, onSubscription = { + log.i { "PMD Control Point (FB005C81) subscribed" } + cpSubscribed.complete(Unit) + }) + .collect { cpData -> + log.d { "PMD CP response: ${cpData.size} bytes: ${cpData.joinToString { (it.toInt() and 0xFF).toString(16) }}" } + pmdCpResponses.tryEmit(cpData) + } + } catch (e: Exception) { + if (!cpSubscribed.isCompleted) { + cpSubscribed.completeExceptionally(e) + } + e.rethrowIfCancellation() + log.e(e) { "PMD CP observer failed: ${e.message}" } + } finally { + if (!cpSubscribed.isCompleted) cpSubscribed.completeExceptionally( + IllegalStateException("PMD control point subscription ended"), + ) + } + } + + // 2. Subscribe to PMD Data (FB005C82) + pmdDataJob = scope.launch { + try { + p.observe(BleConstants.Polar.pmdDataCharacteristic, onSubscription = { + log.i { "PMD Data (FB005C82) subscribed" } + dataSubscribed.complete(Unit) + }) + .collect { data -> + if (p.state.value !is State.Connected) return@collect + val measurement = parsePolarPmdData(data) ?: return@collect + processPmdMeasurement(measurement) + } + } catch (e: Exception) { + if (!dataSubscribed.isCompleted) { + dataSubscribed.completeExceptionally(e) + } + e.rethrowIfCancellation() + log.e(e) { "PMD Data observer failed: ${e.message}" } + } finally { + if (!dataSubscribed.isCompleted) dataSubscribed.completeExceptionally( + IllegalStateException("PMD data subscription ended"), + ) + } + } + + try { + val ready = withTimeoutOrNull(BleConstants.GATT_OPERATION_TIMEOUT_MS) { + cpSubscribed.await() + dataSubscribed.await() + true + } + check(ready == true) { "Polar PMD subscription timed out" } + } catch (error: Exception) { + pmdControlPointJob?.cancel() + pmdDataJob?.cancel() + pmdControlPointJob = null + pmdDataJob = null + throw error + } + true + } + + internal fun processPmdMeasurement(measurement: PolarPmdMeasurement): Unit = sessionLock.withLock { + if (peripheral != null && peripheral?.state?.value !is State.Connected) { + return + } + if (!_isContactDetected.value || isBatteryCritical()) { + log.w { "Polar H10 PMD packet discarded: zero contact or critical battery (contact=${_isContactDetected.value}, batt=${_batteryLevel.value})" } + return + } + + when (measurement) { + is PolarPmdMeasurement.Ecg -> { + if (!hasReceivedFirstEcgPacket) { + hasReceivedFirstEcgPacket = true + logRepo.debug( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] ECG first packet received (130Hz)", + tag = "HRM", + ) + } + val samples = measurement.samplesMicrovolts + val isFlatlineOrZero = samples.isEmpty() || + samples.all { it == 0 } || + (samples.isNotEmpty() && samples.all { it == samples[0] }) + val isRailed = samples.any { abs(it) >= ECG_RAIL_THRESHOLD_UV } + if (isFlatlineOrZero || isRailed) { + log.w { "Polar H10 ECG flatline/railing open-circuit noise detected (flatline=$isFlatlineOrZero, railed=$isRailed). Dropping transient frame." } + _latestEcg.value = null + return + } + lastValidPacketTimestampMs = monotonicClock() + _latestEcg.value = measurement + _ecgStream.tryEmit(measurement) + val currentSid = activeSessionId + if (currentSid != null && _isRawEcgStorageEnabled.value) { + val nowMs = wallClock() + sessionLock.withLock { + val startMs = rawEcgStartMs ?: monotonicClock().also { rawEcgStartMs = it } + if (monotonicClock() - startMs >= MAX_RAW_ECG_DURATION_MS) return@withLock + val retained = measurement.samplesMicrovolts.take(MAX_RAW_ECG_SAMPLES - rawEcgSampleCount) + if (retained.isEmpty()) return@withLock + rawEcgSampleCount += retained.size + bufferedEcgSegments.add( + BufferedEcgSegment( + sensorTimestampNs = measurement.sensorTimestampNs, + wallClockTimestampMs = nowMs, + phase = currentPhase, + samplesUvCsv = retained.joinToString(","), + ) + ) + } + } + } + is PolarPmdMeasurement.Acc -> { + if (!hasReceivedFirstAccPacket) { + hasReceivedFirstAccPacket = true + logRepo.debug( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] ACC first packet received (50Hz)", + tag = "HRM", + ) + } + if (measurement.samples.isEmpty()) return + lastValidPacketTimestampMs = monotonicClock() + _latestAcc.value = measurement + _accStream.tryEmit(measurement) + for (s in measurement.samples) { + val bio = accFilter.processSample(s.xMg, s.yMg, s.zMg) + _torsoPitchDeg.value = bio.pitchDeg + _torsoRollDeg.value = bio.rollDeg + _dynamicAccMg.value = bio.dynamicAccMg + if (activeSessionId != null && frozenSessionSummary == null) { + sessionLock.withLock { + if (maxTorsoPitch == null) { + maxTorsoPitch = bio.pitchDeg + } else { + val currentMax = maxTorsoPitch!! + if (bio.pitchDeg > currentMax) { + maxTorsoPitch = bio.pitchDeg + } else if (currentMax <= 0.0 && abs(bio.pitchDeg) > abs(currentMax)) { + maxTorsoPitch = bio.pitchDeg + } + } + if (maxTorsoRoll == null || abs(bio.rollDeg) > abs(maxTorsoRoll!!)) { + maxTorsoRoll = bio.rollDeg + } + } + } + } + } + } + } + + private suspend fun sendPmdControlCommand( + p: Peripheral, + command: ByteArray, + opCode: Byte, + measurementType: Byte, + timeoutMs: Long = 2000L, + ): Result = pmdCommandMutex.withLock { + ensurePmdSubscribed(p) + // Dwell 50ms before writing to ensure Android GATT queue is clean + delay(50L) + + val responseDeferred = CompletableDeferred() + val responseListener = scope.launch(start = CoroutineStart.UNDISPATCHED) { + pmdCpResponses + .filter { resp -> + resp.size >= 4 && + (resp[0].toInt() and 0xFF) == 0xF0 && + resp[1] == opCode && + resp[2] == measurementType + } + .first() + .let { resp -> + val status = resp[3].toInt() and 0xFF + responseDeferred.complete(status) + } + } + + try { + p.write( + characteristic = BleConstants.Polar.pmdControlPointCharacteristic, + data = command, + writeType = WriteType.WithResponse, + ) + val status = withTimeoutOrNull(timeoutMs) { + responseDeferred.await() + } ?: throw IllegalStateException("Polar PMD control point response timed out") + if (status == 0) { + logRepo.debug( + eventType = LogEventType.COMMAND_RESPONSE, + message = "[HRM] PMD CP response: op=0x${opCode.toString(16)}, type=0x${measurementType.toString(16)}, status=0", + tag = "HRM", + ) + } else { + logRepo.warning( + eventType = LogEventType.COMMAND_RESPONSE, + message = "[HRM] PMD CP non-zero status: op=0x${opCode.toString(16)}, type=0x${measurementType.toString(16)}, status=$status", + tag = "HRM", + ) + } + // Settle 150ms before releasing lock to ensure H10 PMD state machine is ready for next command + delay(150L) + Result.success(status) + } catch (e: Exception) { + e.rethrowIfCancellation() + logRepo.error( + eventType = LogEventType.ERROR, + message = "[HRM] PMD CP write failed: ${e.message}", + details = e.stackTraceToString(), + tag = "HRM", + ) + Result.failure(e) + } finally { + responseListener.cancel() + } + } + + /** + * Start the 130 Hz 14-bit ECG stream. + * Ensures PMD Control Point and Data notifications are active before sending command. + */ + suspend fun startEcgStream(): Result { + val p = peripheral ?: return Result.failure(IllegalStateException("Polar H10 not connected")) + return try { + log.i { "Starting Polar H10 ECG stream" } + logRepo.info( + eventType = LogEventType.COMMAND_SENT, + message = "[HRM] Starting 130Hz ECG stream", + tag = "HRM", + ) + hasReceivedFirstEcgPacket = false + val status = sendPmdControlCommand( + p = p, + command = BleConstants.Polar.PMD_START_ECG_CMD, + opCode = 0x02, + measurementType = 0x00, + ).getOrThrow() + if (status != 0) { + return Result.failure(IllegalStateException("Polar H10 rejected ECG start: status $status")) + } + _isEcgStreaming.value = true + + // Watchdog: If zero ECG packets arrive within 1500ms, retry START_ECG command once + scope.launch { + delay(1500L) + if (_isEcgStreaming.value && !hasReceivedFirstEcgPacket && _isConnected.value) { + log.w { "No ECG packets received within 1500ms of start command; re-sending START_ECG once" } + logRepo.warning( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] ECG packet timeout (1500ms); re-transmitting START_ECG", + tag = "HRM", + ) + val activePeripheral = peripheral + if (activePeripheral != null && _connectionState.value is ConnectionState.Connected) { + sendPmdControlCommand( + p = activePeripheral, + command = BleConstants.Polar.PMD_START_ECG_CMD, + opCode = 0x02, + measurementType = 0x00, + ) + } + } + } + + Result.success(Unit) + } catch (e: Exception) { + e.rethrowIfCancellation() + log.e(e) { "Failed to start ECG stream: ${e.message}" } + logRepo.error( + eventType = LogEventType.ERROR, + message = "[HRM] Failed to start ECG stream: ${e.message}", + details = e.stackTraceToString(), + tag = "HRM", + ) + Result.failure(e) + } + } + + /** + * Stop the ECG stream. + */ + suspend fun stopEcgStream(): Result { + val p = peripheral ?: return Result.failure(IllegalStateException("Polar H10 not connected")) + return try { + log.i { "Stopping Polar H10 ECG stream" } + logRepo.info( + eventType = LogEventType.COMMAND_SENT, + message = "[HRM] Stopping 130Hz ECG stream", + tag = "HRM", + ) + sendPmdControlCommand( + p = p, + command = BleConstants.Polar.PMD_STOP_ECG_CMD, + opCode = 0x03, + measurementType = 0x00, + ).getOrThrow().also { check(it == 0) { "Polar H10 rejected ECG stop: status $it" } } + _isEcgStreaming.value = false + _latestEcg.value = null + checkAndTeardownPmdObservers() + Result.success(Unit) + } catch (e: Exception) { + e.rethrowIfCancellation() + log.e(e) { "Failed to stop ECG stream: ${e.message}" } + logRepo.error( + eventType = LogEventType.ERROR, + message = "[HRM] Failed to stop ECG stream: ${e.message}", + details = e.stackTraceToString(), + tag = "HRM", + ) + Result.failure(e) + } + } + + /** + * Start the 50 Hz 16-bit ACC stream (±8G range). + * Ensures PMD Control Point and Data notifications are active before sending command. + * Automatically retries with ascending TLV order or 25Hz/2G fallback if range-first TLV is rejected. + */ + suspend fun startAccStream(): Result { + val p = peripheral ?: return Result.failure(IllegalStateException("Polar H10 not connected")) + return try { + log.i { "Starting Polar H10 ACC stream" } + logRepo.info( + eventType = LogEventType.COMMAND_SENT, + message = "[HRM] Starting 50Hz ACC stream", + tag = "HRM", + ) + hasReceivedFirstAccPacket = false + // Try 1: standard command (range-first) + var status = sendPmdControlCommand( + p = p, + command = BleConstants.Polar.PMD_START_ACC_CMD, + opCode = 0x02, + measurementType = 0x02, + ).getOrThrow() + + if (status != 0) { + log.w { "Starting ACC with range-first TLV returned status $status. Retrying with ascending TLV (50Hz, 16-bit, 8G)..." } + status = sendPmdControlCommand( + p = p, + command = BleConstants.Polar.PMD_START_ACC_ASCENDING_CMD, + opCode = 0x02, + measurementType = 0x02, + ).getOrThrow() + } + + if (status != 0) { + log.w { "Starting ACC with ascending TLV returned status $status. Retrying with 25Hz/2G fallback..." } + status = sendPmdControlCommand( + p = p, + command = BleConstants.Polar.PMD_START_ACC_FALLBACK_25HZ_CMD, + opCode = 0x02, + measurementType = 0x02, + ).getOrThrow() + } + + if (status != 0) { + log.e { "Polar H10 rejected ACC start command with status $status" } + logRepo.error( + eventType = LogEventType.COMMAND_RESPONSE, + message = "[HRM] ACC start command rejected: status $status", + tag = "HRM", + ) + return Result.failure(IllegalStateException("Polar H10 rejected ACC start command: status $status")) + } + + _isAccStreaming.value = true + Result.success(Unit) + } catch (e: Exception) { + e.rethrowIfCancellation() + log.e(e) { "Failed to start ACC stream: ${e.message}" } + logRepo.error( + eventType = LogEventType.ERROR, + message = "[HRM] Failed to start ACC stream: ${e.message}", + details = e.stackTraceToString(), + tag = "HRM", + ) + Result.failure(e) + } + } + + /** + * Stop the ACC stream. + */ + suspend fun stopAccStream(): Result { + val p = peripheral ?: return Result.failure(IllegalStateException("Polar H10 not connected")) + return try { + log.i { "Stopping Polar H10 ACC stream" } + logRepo.info( + eventType = LogEventType.COMMAND_SENT, + message = "[HRM] Stopping 50Hz ACC stream", + tag = "HRM", + ) + sendPmdControlCommand( + p = p, + command = BleConstants.Polar.PMD_STOP_ACC_CMD, + opCode = 0x03, + measurementType = 0x02, + ).getOrThrow().also { check(it == 0) { "Polar H10 rejected ACC stop: status $it" } } + _isAccStreaming.value = false + _latestAcc.value = null + accFilter.reset() + _torsoPitchDeg.value = null + _torsoRollDeg.value = null + _dynamicAccMg.value = null + checkAndTeardownPmdObservers() + Result.success(Unit) + } catch (e: Exception) { + e.rethrowIfCancellation() + log.e(e) { "Failed to stop ACC stream: ${e.message}" } + logRepo.error( + eventType = LogEventType.ERROR, + message = "[HRM] Failed to stop ACC stream: ${e.message}", + details = e.stackTraceToString(), + tag = "HRM", + ) + Result.failure(e) + } + } + + /** + * Non-suspending helper to stop ECG and ACC streams asynchronously on the manager's scope. + * Safe to invoke from [DisposableEffect.onDispose]. + */ + fun stopTelemetryStreamsAsync() { + scope.launch { stopUnusedTelemetryStreams() } + } + + fun releaseTelemetryPreviewAsync() = stopTelemetryStreamsAsync() + + // ===== Watchdog for Packet Absence (> 3s) ===== + private var watchdogJob: Job? = null + private var contactLossJob: Job? = null + private var lastValidPacketTimestampMs: Long = 0L + + internal fun checkPacketAbsenceTimeout(currentTimestampMs: Long = monotonicClock()): Unit = sessionLock.withLock { + if (_isCooldownActive.value && + lastHeartRateMonotonicMs?.let { currentTimestampMs - it > 3000L } != false + ) { + _hrMeasurement.value = null + cancelCooldown() + } + if (lastValidPacketTimestampMs > 0L && (currentTimestampMs - lastValidPacketTimestampMs) > 3000L) { + if (_hrMeasurement.value != null || _latestEcg.value != null || _latestAcc.value != null || _torsoPitchDeg.value != null) { + log.w { "Polar H10 packet absence timeout (>3s). Clearing telemetry streams." } + logRepo.warning( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] Stream packet absence timeout (>3s). Clearing telemetry.", + tag = "HRM", + ) + clearCachedTelemetry() + if (_isCooldownActive.value) { + log.i { "Terminating recovery cooldown due to packet absence timeout (>3s)" } + cancelCooldown() + } + } + } + } + + private fun startWatchdog() { + watchdogJob?.cancel() + lastValidPacketTimestampMs = monotonicClock() + watchdogJob = scope.launch { + var lastStreamRetryMs = monotonicClock() + while (isActive && _isConnected.value) { + delay(500L) + val nowMs = monotonicClock() + checkPacketAbsenceTimeout(nowMs) + if (nowMs - lastStreamRetryMs >= 5000L && + sessionLock.withLock { activeSessionId != null || _isCooldownActive.value } && + (!_isEcgStreaming.value || !_isAccStreaming.value || + pmdControlPointJob?.isActive != true || pmdDataJob?.isActive != true) + ) { + lastStreamRetryMs = nowMs + scope.launch { ensureRecordingTelemetryStreams() } + } + } + } + } + + /** + * Disconnect from the Polar H10 device and clean up all active telemetry streams. + */ + suspend fun disconnect() { + _isConnected.value = false + isExplicitDisconnect = true + reconnectJob?.cancel() + reconnectJob = null + + val p = peripheral + if (p != null) { + log.i { "Disconnecting Polar H10" } + logRepo.info( + eventType = LogEventType.DISCONNECT, + message = "[HRM] Disconnecting Polar H10", + tag = "HRM", + ) + try { + if (_isEcgStreaming.value) { + try { stopEcgStream() } catch (_: Exception) {} + } + if (_isAccStreaming.value) { + try { stopAccStream() } catch (_: Exception) {} + } + peripheral = null + p.disconnect() + } catch (e: Exception) { + e.rethrowIfCancellation() + log.w { "Error during Polar disconnect: ${e.message}" } + } + try { + p.close() + } catch (e: Exception) { + e.rethrowIfCancellation() + log.w { "Error during Polar close: ${e.message}" } + } + } + cleanupExistingConnection() + _connectionState.value = ConnectionState.Disconnected + } + + private fun cleanupSessionTelemetry() { + contactLossJob?.cancel() + contactLossJob = null + hrObserveJob?.cancel() + hrObserveJob = null + batteryObserveJob?.cancel() + batteryObserveJob = null + + pmdControlPointJob?.cancel() + pmdControlPointJob = null + pmdDataJob?.cancel() + pmdDataJob = null + + watchdogJob?.cancel() + watchdogJob = null + + _isEcgStreaming.value = false + _isAccStreaming.value = false + + _isConnected.value = false + _batteryLevel.value = null + clearCachedTelemetry(resetHeartRateClock = true) + + if (_isCooldownActive.value) { + cancelCooldown() + } + } + + internal fun clearCachedTelemetry(resetHeartRateClock: Boolean = false): Unit = sessionLock.withLock { + if (previousContactDetected == true) { + previousContactDetected = false + logRepo.debug( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] Contact lost (telemetry cleared)", + tag = "HRM", + ) + } + _isContactDetected.value = false + lastBeatTimestampMs = null + lastBeatMonotonicMs = null + if (resetHeartRateClock) lastHeartRateMonotonicMs = null + _hrMeasurement.value = null + _latestEcg.value = null + _latestAcc.value = null + + accFilter.reset() + _torsoPitchDeg.value = null + _torsoRollDeg.value = null + _dynamicAccMg.value = null + } + + private suspend fun cleanupExistingConnection() { + stateObserverJob?.cancel() + stateObserverJob = null + cleanupSessionTelemetry() + val p = peripheral + peripheral = null + if (p != null) { + try { + p.disconnect() + } catch (e: Exception) { + e.rethrowIfCancellation() + log.w { "Cleanup disconnect error (non-fatal): ${e.message}" } + } + try { + p.close() + } catch (e: Exception) { + e.rethrowIfCancellation() + log.w { "Cleanup close error (non-fatal): ${e.message}" } + } + } + wasEverConnected = false + } + + fun shutdown() { + _isConnected.value = false + isExplicitDisconnect = true + scanJob?.cancel() + scanJob = null + reconnectJob?.cancel() + reconnectJob = null + stateObserverJob?.cancel() + stateObserverJob = null + val p = peripheral + peripheral = null + wasEverConnected = false + cleanupSessionTelemetry() + _connectionState.value = ConnectionState.Disconnected + if (p != null) { + scope.launch { + try { + if (_isEcgStreaming.value) { + try { stopEcgStream() } catch (_: Exception) {} + } + if (_isAccStreaming.value) { + try { stopAccStream() } catch (_: Exception) {} + } + p.disconnect() + } catch (_: Exception) {} + try { + p.close() + } catch (_: Exception) {} + } + } + } + + internal fun isPolarH10Advertisement(adv: Advertisement): Boolean { + val name = adv.name ?: return false + return name.equals("Polar H10", ignoreCase = true) || name.startsWith("Polar H10 ", ignoreCase = true) + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParser.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParser.kt new file mode 100644 index 000000000..bec2131ca --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParser.kt @@ -0,0 +1,234 @@ +package com.devil.phoenixproject.data.ble + +import com.devil.phoenixproject.domain.model.PolarAccSample +import com.devil.phoenixproject.domain.model.PolarHrMeasurement +import com.devil.phoenixproject.domain.model.PolarPmdMeasurement + +/** + * Pure byte parsing utility functions for Polar H10 telemetry. + * + * Implements decoders for: + * - Heart Rate & RR Intervals (Standard Bluetooth SIG 0x2A37) + * - Battery Level (Standard Bluetooth SIG 0x2A19) + * - Polar Measurement Data / PMD ECG & ACC (Polar Proprietary FB005C82) + * + * All functions are stateless and thread-safe. + */ + +/** + * Read signed 64-bit integer in LITTLE-ENDIAN format (LSB first). + * Used for sensor timestamps in PMD packets. + */ +fun getInt64LE(data: ByteArray, offset: Int): Long = + (data[offset].toLong() and 0xFFL) or + ((data[offset + 1].toLong() and 0xFFL) shl 8) or + ((data[offset + 2].toLong() and 0xFFL) shl 16) or + ((data[offset + 3].toLong() and 0xFFL) shl 24) or + ((data[offset + 4].toLong() and 0xFFL) shl 32) or + ((data[offset + 5].toLong() and 0xFFL) shl 40) or + ((data[offset + 6].toLong() and 0xFFL) shl 48) or + ((data[offset + 7].toLong() and 0xFFL) shl 56) + +/** + * Read signed bit-packed value of length [bitCount] (1..16 bits) from [data] + * starting at [startByte] and [bitOffset]. + */ +internal fun readSignedBits(data: ByteArray, startByte: Int, bitOffset: Int, bitCount: Int): Int { + if (bitCount <= 0 || bitCount > 16) return 0 + val byteIdx = startByte + (bitOffset / 8) + val bitInByte = bitOffset % 8 + if (byteIdx >= data.size) return 0 + + val b0 = data[byteIdx].toInt() and 0xFF + val b1 = if (byteIdx + 1 < data.size) data[byteIdx + 1].toInt() and 0xFF else 0 + val b2 = if (byteIdx + 2 < data.size) data[byteIdx + 2].toInt() and 0xFF else 0 + val raw24 = b0 or (b1 shl 8) or (b2 shl 16) + + val unsigned = (raw24 ushr bitInByte) and ((1 shl bitCount) - 1) + val signBit = 1 shl (bitCount - 1) + return if ((unsigned and signBit) != 0) unsigned - (1 shl bitCount) else unsigned +} + +/** + * Parse GATT Heart Rate Measurement (0x2A37) payload into [PolarHrMeasurement]. + * + * Layout: + * - Flags (byte 0): + * - bit 0: HR format (0 = uint8, 1 = uint16) + * - bits 1-2: Sensor contact status (11b = contact detected, 10b = not detected) + * - bit 3: Energy expended present (0 = false, 1 = true) + * - bit 4: RR intervals present (0 = false, 1 = true) + * - HR: 1 byte or 2 bytes LE depending on bit 0 + * - Energy expended (optional): 2 bytes uint16 LE (kiloJoules) + * - RR intervals (optional): pairs of uint16 LE, units of 1/1024s (converted to ms) + * + * @param data The raw byte array received from characteristic 0x2A37. + * @return Parsed [PolarHrMeasurement] or null if packet is truncated or invalid. + */ +fun parsePolarHrMeasurement(data: ByteArray): PolarHrMeasurement? { + if (data.isEmpty()) return null + + val flags = data[0].toInt() and 0xFF + val hrFormat16Bit = (flags and 0x01) != 0 + val energyPresent = (flags and 0x08) != 0 + val rrPresent = (flags and 0x10) != 0 + + var offset = 1 + val hr: Int + if (hrFormat16Bit) { + if (data.size < offset + 2) return null + hr = getUInt16LE(data, offset) + offset += 2 + } else { + if (data.size < offset + 1) return null + hr = data[offset].toInt() and 0xFF + offset += 1 + } + + // Bits 1..2 of flags: + // 0 or 1: Contact sensor not supported -> treated as contact OK (true) if hr > 0 + // 2 (0b10): Contact supported, NOT detected -> false (poor skin contact) + // 3 (0b11): Contact supported and detected -> true (good skin contact) + val contactStatus = (flags shr 1) and 0x03 + val contactDetected = when (contactStatus) { + 2 -> false + 3 -> true + else -> hr > 0 + } + + var energyExpendedKj: Int? = null + if (energyPresent) { + if (data.size < offset + 2) return null + energyExpendedKj = getUInt16LE(data, offset) + offset += 2 + } + + val rrIntervalsMs = mutableListOf() + if (rrPresent) { + while (offset + 2 <= data.size) { + val rawRr = getUInt16LE(data, offset) + offset += 2 + val ms = (rawRr * 1000.0) / 1024.0 + rrIntervalsMs.add(ms) + } + } + + return PolarHrMeasurement( + hr = hr, + contactDetected = contactDetected, + energyExpendedKj = energyExpendedKj, + rrIntervalsMs = rrIntervalsMs, + ) +} + +/** + * Parse Battery Level (0x2A19) payload into percentage (0..100). + */ +fun parsePolarBatteryLevel(data: ByteArray): Int? { + if (data.isEmpty()) return null + return (data[0].toInt() and 0xFF).coerceIn(0, 100) +} + +/** + * Parse Polar Measurement Data (PMD) stream packet (FB005C82) into [PolarPmdMeasurement]. + * + * Supports: + * - ECG (type 0x00, uncompressed 24-bit signed LE samples in microvolts) + * - ACC (type 0x02, uncompressed 8/16-bit signed triplets or delta-compressed bit-packed frames) + * + * @param data The raw byte array received from characteristic FB005C82. + * @return [PolarPmdMeasurement.Ecg], [PolarPmdMeasurement.Acc], or null if packet is invalid. + */ +fun parsePolarPmdData(data: ByteArray): PolarPmdMeasurement? { + if (data.size < 10) return null + + val measurementType = data[0].toInt() and 0xFF + val sensorTimestampNs = getInt64LE(data, 1) + val frameType = data[9].toInt() and 0xFF + + return when (measurementType) { + 0x00 -> parseEcgFrame(data, sensorTimestampNs) + 0x02 -> parseAccFrame(data, sensorTimestampNs, frameType) + else -> null + } +} + +private fun parseEcgFrame(data: ByteArray, timestampNs: Long): PolarPmdMeasurement.Ecg { + val samples = mutableListOf() + var offset = 10 + while (offset + 3 <= data.size) { + var uv = (data[offset].toInt() and 0xFF) or + ((data[offset + 1].toInt() and 0xFF) shl 8) or + ((data[offset + 2].toInt() and 0xFF) shl 16) + if ((uv and 0x800000) != 0) { + uv -= 0x1000000 + } + samples.add(uv) + offset += 3 + } + return PolarPmdMeasurement.Ecg( + sensorTimestampNs = timestampNs, + samplesMicrovolts = samples, + ) +} + +private fun parseAccFrame(data: ByteArray, timestampNs: Long, frameType: Int): PolarPmdMeasurement.Acc? { + val isCompressed = (frameType and 0x80) != 0 + return if (!isCompressed) { + val axisByteCount = when (frameType) { + 0x00 -> 1 + 0x01 -> 2 + else -> return null + } + val samples = mutableListOf() + var offset = 10 + while (offset + axisByteCount * 3 <= data.size) { + val x = if (axisByteCount == 1) data[offset].toInt() else getInt16LE(data, offset) + val y = if (axisByteCount == 1) data[offset + 1].toInt() else getInt16LE(data, offset + 2) + val z = if (axisByteCount == 1) data[offset + 2].toInt() else getInt16LE(data, offset + 4) + samples.add(PolarAccSample(x, y, z)) + offset += axisByteCount * 3 + } + PolarPmdMeasurement.Acc( + sensorTimestampNs = timestampNs, + samples = samples, + ) + } else { + if (frameType != 0x80 && frameType != 0x81) return null + // Delta-compressed frame - frameType 0x80, 0x81 + if (data.size < 16) return null + var currentX = getInt16LE(data, 10) + var currentY = getInt16LE(data, 12) + var currentZ = getInt16LE(data, 14) + val samples = mutableListOf() + // The baseline sample at index 10..15 is the initial reference sample + samples.add(PolarAccSample(currentX, currentY, currentZ)) + + var offset = 16 + while (offset < data.size) { + if (data.size - offset < 2) return null + val deltaSizeBits = data[offset].toInt() and 0xFF + val sampleCount = data[offset + 1].toInt() and 0xFF + offset += 2 + if (deltaSizeBits > 16 || (deltaSizeBits == 0 && sampleCount > 0)) return null + val blockByteCount = (sampleCount * deltaSizeBits * 3 + 7) / 8 + if (data.size - offset < blockByteCount) return null + var bitPos = 0 + repeat(sampleCount) { + currentX += readSignedBits(data, offset, bitPos, deltaSizeBits) + bitPos += deltaSizeBits + currentY += readSignedBits(data, offset, bitPos, deltaSizeBits) + bitPos += deltaSizeBits + currentZ += readSignedBits(data, offset, bitPos, deltaSizeBits) + bitPos += deltaSizeBits + samples.add(PolarAccSample(currentX, currentY, currentZ)) + } + offset += blockByteCount + } + + PolarPmdMeasurement.Acc( + sensorTimestampNs = timestampNs, + samples = samples, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt index e1b59e1ef..225449f9d 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt @@ -1300,8 +1300,8 @@ WHERE gs.rowid = ( "CREATE INDEX IF NOT EXISTS idx_routine_recovery_profile ON RoutineRecovery(profile_id, expires_at)", ) - // Migration 58: profile-scoped custom equipment names (Issue #1227). Mirrors - // 58.sqm exactly: the CUSTOM_EQUIPMENT section columns on UserProfilePreferences. + // Migration 58: profile-scoped custom equipment names (Issue #1227). + // Mirrors 58.sqm exactly. 58 -> listOf( "ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_json TEXT NOT NULL DEFAULT '{\"version\":1,\"items\":[]}'", "ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_updated_at INTEGER NOT NULL DEFAULT 0", @@ -1310,5 +1310,51 @@ WHERE gs.rowid = ( "ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_dirty INTEGER NOT NULL DEFAULT 0", ) + // Migration 59: Polar H10 multi-tier telemetry persistence. Mirrors 59.sqm exactly. + 59 -> listOf( + "ALTER TABLE MetricSample ADD COLUMN heartRateBpm INTEGER", + "ALTER TABLE MetricSample ADD COLUMN rrIntervalMs REAL", + "ALTER TABLE MetricSample ADD COLUMN torsoPitchDeg REAL", + "ALTER TABLE MetricSample ADD COLUMN torsoRollDeg REAL", + "ALTER TABLE MetricSample ADD COLUMN dynamicAccMg INTEGER", + "ALTER TABLE MetricSample ADD COLUMN chestAccXMg INTEGER", + "ALTER TABLE MetricSample ADD COLUMN chestAccYMg INTEGER", + "ALTER TABLE MetricSample ADD COLUMN chestAccZMg INTEGER", + """CREATE TABLE IF NOT EXISTS PolarBeatSample ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sessionId TEXT NOT NULL, + timestampMs INTEGER NOT NULL, + phase TEXT NOT NULL, + hrBpm INTEGER NOT NULL, + rrMs REAL NOT NULL, + rmssdMs REAL, + torsoPitchDeg REAL, + torsoRollDeg REAL, + dynamicAccMg INTEGER, + FOREIGN KEY (sessionId) REFERENCES WorkoutSession(id) ON DELETE CASCADE + )""", + "CREATE INDEX IF NOT EXISTS idx_polar_beat_sample_session_ts ON PolarBeatSample(sessionId, timestampMs)", + """CREATE TABLE IF NOT EXISTS PolarEcgSegment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sessionId TEXT NOT NULL, + sensorTimestampNs INTEGER NOT NULL, + wallClockTimestampMs INTEGER NOT NULL, + phase TEXT NOT NULL, + samplesUvCsv TEXT NOT NULL, + FOREIGN KEY (sessionId) REFERENCES WorkoutSession(id) ON DELETE CASCADE + )""", + "CREATE INDEX IF NOT EXISTS idx_polar_ecg_segment_session_wall ON PolarEcgSegment(sessionId, wallClockTimestampMs)", + "ALTER TABLE WorkoutSession ADD COLUMN avgHeartRateBpm INTEGER", + "ALTER TABLE WorkoutSession ADD COLUMN maxHeartRateBpm INTEGER", + "ALTER TABLE WorkoutSession ADD COLUMN minHeartRateBpm INTEGER", + "ALTER TABLE WorkoutSession ADD COLUMN avgRrIntervalMs REAL", + "ALTER TABLE WorkoutSession ADD COLUMN rmssdMs REAL", + "ALTER TABLE WorkoutSession ADD COLUMN hrr60Bpm INTEGER", + "ALTER TABLE WorkoutSession ADD COLUMN hrr300Bpm INTEGER", + "ALTER TABLE WorkoutSession ADD COLUMN maxTorsoPitchDeg REAL", + "ALTER TABLE WorkoutSession ADD COLUMN maxTorsoRollDeg REAL", + ) + else -> emptyList() } + diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt index c4dd182f6..eadf6b96f 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt @@ -1117,12 +1117,21 @@ internal val manifestTables: List = listOf( portalOrigin INTEGER NOT NULL DEFAULT 0 CHECK(portalOrigin IN (0, 1)), local_sync_generation INTEGER NOT NULL DEFAULT 1 CHECK(local_sync_generation >= 0), synced_sync_generation INTEGER NOT NULL DEFAULT 0 - CHECK(synced_sync_generation >= 0 AND synced_sync_generation <= local_sync_generation) + CHECK(synced_sync_generation >= 0 AND synced_sync_generation <= local_sync_generation), + avgHeartRateBpm INTEGER, + maxHeartRateBpm INTEGER, + minHeartRateBpm INTEGER, + avgRrIntervalMs REAL, + rmssdMs REAL, + hrr60Bpm INTEGER, + hrr300Bpm INTEGER, + maxTorsoPitchDeg REAL, + maxTorsoRollDeg REAL ) """.trimIndent(), ), - // MetricSample -- initial schema, full shape (no later migrations add columns) + // MetricSample -- initial schema, full shape with Polar telemetry (migration 59) SchemaTableOperation( table = "MetricSample", createSql = """ @@ -1138,6 +1147,50 @@ internal val manifestTables: List = listOf( loadB REAL, power REAL, status INTEGER NOT NULL DEFAULT 0, + heartRateBpm INTEGER, + rrIntervalMs REAL, + torsoPitchDeg REAL, + torsoRollDeg REAL, + dynamicAccMg INTEGER, + chestAccXMg INTEGER, + chestAccYMg INTEGER, + chestAccZMg INTEGER, + FOREIGN KEY (sessionId) REFERENCES WorkoutSession(id) ON DELETE CASCADE + ) + """.trimIndent(), + ), + + // PolarBeatSample -- migration 59 + SchemaTableOperation( + table = "PolarBeatSample", + createSql = """ + CREATE TABLE IF NOT EXISTS PolarBeatSample ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sessionId TEXT NOT NULL, + timestampMs INTEGER NOT NULL, + phase TEXT NOT NULL, + hrBpm INTEGER NOT NULL, + rrMs REAL NOT NULL, + rmssdMs REAL, + torsoPitchDeg REAL, + torsoRollDeg REAL, + dynamicAccMg INTEGER, + FOREIGN KEY (sessionId) REFERENCES WorkoutSession(id) ON DELETE CASCADE + ) + """.trimIndent(), + ), + + // PolarEcgSegment -- migration 59 + SchemaTableOperation( + table = "PolarEcgSegment", + createSql = """ + CREATE TABLE IF NOT EXISTS PolarEcgSegment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sessionId TEXT NOT NULL, + sensorTimestampNs INTEGER NOT NULL, + wallClockTimestampMs INTEGER NOT NULL, + phase TEXT NOT NULL, + samplesUvCsv TEXT NOT NULL, FOREIGN KEY (sessionId) REFERENCES WorkoutSession(id) ON DELETE CASCADE ) """.trimIndent(), @@ -1719,6 +1772,27 @@ internal val manifestColumns: List = listOf( "synced_sync_generation", "ALTER TABLE WorkoutSession ADD COLUMN synced_sync_generation INTEGER NOT NULL DEFAULT 0 CHECK(synced_sync_generation >= 0 AND synced_sync_generation <= local_sync_generation)", ), + // Migration 59: Polar H10 cardiac & torso biomechanics summary + SchemaHealOperation("WorkoutSession", "avgHeartRateBpm", "ALTER TABLE WorkoutSession ADD COLUMN avgHeartRateBpm INTEGER"), + SchemaHealOperation("WorkoutSession", "maxHeartRateBpm", "ALTER TABLE WorkoutSession ADD COLUMN maxHeartRateBpm INTEGER"), + SchemaHealOperation("WorkoutSession", "minHeartRateBpm", "ALTER TABLE WorkoutSession ADD COLUMN minHeartRateBpm INTEGER"), + SchemaHealOperation("WorkoutSession", "avgRrIntervalMs", "ALTER TABLE WorkoutSession ADD COLUMN avgRrIntervalMs REAL"), + SchemaHealOperation("WorkoutSession", "rmssdMs", "ALTER TABLE WorkoutSession ADD COLUMN rmssdMs REAL"), + SchemaHealOperation("WorkoutSession", "hrr60Bpm", "ALTER TABLE WorkoutSession ADD COLUMN hrr60Bpm INTEGER"), + SchemaHealOperation("WorkoutSession", "hrr300Bpm", "ALTER TABLE WorkoutSession ADD COLUMN hrr300Bpm INTEGER"), + SchemaHealOperation("WorkoutSession", "maxTorsoPitchDeg", "ALTER TABLE WorkoutSession ADD COLUMN maxTorsoPitchDeg REAL"), + SchemaHealOperation("WorkoutSession", "maxTorsoRollDeg", "ALTER TABLE WorkoutSession ADD COLUMN maxTorsoRollDeg REAL"), + + // ── MetricSample (8 columns) ───────────────────────────────────────── + // Migration 59: Polar H10 intra-set synchronized telemetry & torso biomechanics + SchemaHealOperation("MetricSample", "heartRateBpm", "ALTER TABLE MetricSample ADD COLUMN heartRateBpm INTEGER"), + SchemaHealOperation("MetricSample", "rrIntervalMs", "ALTER TABLE MetricSample ADD COLUMN rrIntervalMs REAL"), + SchemaHealOperation("MetricSample", "torsoPitchDeg", "ALTER TABLE MetricSample ADD COLUMN torsoPitchDeg REAL"), + SchemaHealOperation("MetricSample", "torsoRollDeg", "ALTER TABLE MetricSample ADD COLUMN torsoRollDeg REAL"), + SchemaHealOperation("MetricSample", "dynamicAccMg", "ALTER TABLE MetricSample ADD COLUMN dynamicAccMg INTEGER"), + SchemaHealOperation("MetricSample", "chestAccXMg", "ALTER TABLE MetricSample ADD COLUMN chestAccXMg INTEGER"), + SchemaHealOperation("MetricSample", "chestAccYMg", "ALTER TABLE MetricSample ADD COLUMN chestAccYMg INTEGER"), + SchemaHealOperation("MetricSample", "chestAccZMg", "ALTER TABLE MetricSample ADD COLUMN chestAccZMg INTEGER"), // ── PersonalRecord (7 columns) ────────────────────────────────────── @@ -1934,6 +2008,12 @@ internal val manifestIndexes: List = listOf( // ── MetricSample ──────────────────────────────────────────────────── SchemaIndexOperation("idx_metric_sample_session", "CREATE INDEX IF NOT EXISTS idx_metric_sample_session ON MetricSample(sessionId)"), + // ── PolarBeatSample (migration 59) ────────────────────────────────── + SchemaIndexOperation("idx_polar_beat_sample_session_ts", "CREATE INDEX IF NOT EXISTS idx_polar_beat_sample_session_ts ON PolarBeatSample(sessionId, timestampMs)"), + + // ── PolarEcgSegment (migration 59) ────────────────────────────────── + SchemaIndexOperation("idx_polar_ecg_segment_session_wall", "CREATE INDEX IF NOT EXISTS idx_polar_ecg_segment_session_wall ON PolarEcgSegment(sessionId, wallClockTimestampMs)"), + // ── PersonalRecord ────────────────────────────────────────────────── // idx_pr_unique changed shape: migration 19 added phase, migration 21 added profile_id. // preDropSql ensures we replace any stale version with the canonical shape. diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/Stubs.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/Stubs.kt index 8df4c3638..20b955633 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/Stubs.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/Stubs.kt @@ -13,4 +13,5 @@ data class ConnectionLogEntity( val deviceName: String? = null, val message: String, val details: String? = null, + val tag: String? = null, ) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ConnectionLogRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ConnectionLogRepository.kt index 88fef5540..0d0d451b2 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ConnectionLogRepository.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ConnectionLogRepository.kt @@ -85,6 +85,7 @@ class ConnectionLogRepository { deviceName: String? = null, deviceAddress: String? = null, details: String? = null, + tag: String? = null, ) { if (!_isEnabled.value) return @@ -98,6 +99,7 @@ class ConnectionLogRepository { deviceName = deviceName, message = message, details = details, + tag = tag, ) _logs.update { currentLogs -> @@ -113,20 +115,48 @@ class ConnectionLogRepository { /** * Convenience methods for different log levels. */ - fun debug(eventType: String, message: String, deviceName: String? = null, deviceAddress: String? = null, details: String? = null) { - log(LogLevel.DEBUG, eventType, message, deviceName, deviceAddress, details) + fun debug( + eventType: String, + message: String, + deviceName: String? = null, + deviceAddress: String? = null, + details: String? = null, + tag: String? = null, + ) { + log(LogLevel.DEBUG, eventType, message, deviceName, deviceAddress, details, tag) } - fun info(eventType: String, message: String, deviceName: String? = null, deviceAddress: String? = null, details: String? = null) { - log(LogLevel.INFO, eventType, message, deviceName, deviceAddress, details) + fun info( + eventType: String, + message: String, + deviceName: String? = null, + deviceAddress: String? = null, + details: String? = null, + tag: String? = null, + ) { + log(LogLevel.INFO, eventType, message, deviceName, deviceAddress, details, tag) } - fun warning(eventType: String, message: String, deviceName: String? = null, deviceAddress: String? = null, details: String? = null) { - log(LogLevel.WARNING, eventType, message, deviceName, deviceAddress, details) + fun warning( + eventType: String, + message: String, + deviceName: String? = null, + deviceAddress: String? = null, + details: String? = null, + tag: String? = null, + ) { + log(LogLevel.WARNING, eventType, message, deviceName, deviceAddress, details, tag) } - fun error(eventType: String, message: String, deviceName: String? = null, deviceAddress: String? = null, details: String? = null) { - log(LogLevel.ERROR, eventType, message, deviceName, deviceAddress, details) + fun error( + eventType: String, + message: String, + deviceName: String? = null, + deviceAddress: String? = null, + details: String? = null, + tag: String? = null, + ) { + log(LogLevel.ERROR, eventType, message, deviceName, deviceAddress, details, tag) } /** @@ -154,7 +184,8 @@ class ConnectionLogRepository { sb.appendLine() _logs.value.forEach { log -> - sb.appendLine("[${formatTimestamp(log.timestamp)}] [${log.level}] ${log.eventType}") + val tagStr = if (log.tag != null) " [${log.tag}]" else "" + sb.appendLine("[${formatTimestamp(log.timestamp)}] [${log.level}]$tagStr ${log.eventType}") sb.appendLine(" ${log.message}") if (log.deviceName != null || log.deviceAddress != null) { sb.appendLine( diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/PolarTelemetryRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/PolarTelemetryRepository.kt new file mode 100644 index 000000000..e8fb36e43 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/PolarTelemetryRepository.kt @@ -0,0 +1,16 @@ +package com.devil.phoenixproject.data.repository + +import com.devil.phoenixproject.domain.model.PolarBeatSample +import com.devil.phoenixproject.domain.model.PolarEcgSegment +import com.devil.phoenixproject.domain.model.PolarRecovery + +interface PolarTelemetryRepository { + suspend fun saveTelemetry( + sessionId: String, + beats: List, + ecgs: List, + recovery: PolarRecovery? = null, + ) + + suspend fun pruneRawEcg() +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightPolarTelemetryRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightPolarTelemetryRepository.kt new file mode 100644 index 000000000..624fd3ce9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightPolarTelemetryRepository.kt @@ -0,0 +1,80 @@ +package com.devil.phoenixproject.data.repository + +import com.devil.phoenixproject.database.PhoenixDatabase +import com.devil.phoenixproject.domain.model.PolarBeatSample +import com.devil.phoenixproject.domain.model.PolarEcgSegment +import com.devil.phoenixproject.domain.model.PolarRecovery +import com.devil.phoenixproject.domain.model.currentTimeMillis +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext + +class SqlDelightPolarTelemetryRepository( + private val db: PhoenixDatabase, + private val wallClock: () -> Long = ::currentTimeMillis, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, +) : PolarTelemetryRepository { + companion object { + const val RAW_ECG_RETENTION_DAYS = 30 + const val MAX_STORED_ECG_SEGMENTS = 36_000L + } + + private val queries = db.phoenixDatabaseQueries + + override suspend fun saveTelemetry( + sessionId: String, + beats: List, + ecgs: List, + recovery: PolarRecovery?, + ) { + withContext(dispatcher) { + db.transaction { + if (queries.selectSessionById(sessionId).executeAsOneOrNull() == null) return@transaction + beats.forEach { beat -> + queries.insertPolarBeatSample( + sessionId = sessionId, + timestampMs = beat.timestampMs, + phase = beat.phase, + hrBpm = beat.hrBpm, + rrMs = beat.rrMs, + rmssdMs = beat.rmssdMs, + torsoPitchDeg = beat.torsoPitchDeg, + torsoRollDeg = beat.torsoRollDeg, + dynamicAccMg = beat.dynamicAccMg, + ) + } + ecgs.forEach { segment -> + queries.insertPolarEcgSegment( + sessionId = sessionId, + sensorTimestampNs = segment.sensorTimestampNs, + wallClockTimestampMs = segment.wallClockTimestampMs, + phase = segment.phase, + samplesUvCsv = segment.samplesUvCsv, + ) + } + recovery?.let { + queries.updateSessionPolarRecovery( + id = sessionId, + hrr60Bpm = it.hrr60Bpm?.toLong(), + hrr300Bpm = it.hrr300Bpm?.toLong(), + ) + } + pruneRawEcgInTransaction() + } + } + } + + override suspend fun pruneRawEcg() { + withContext(dispatcher) { + db.transaction { + pruneRawEcgInTransaction() + } + } + } + + private fun pruneRawEcgInTransaction() { + queries.deleteExpiredPolarEcgSegments(wallClock() - RAW_ECG_RETENTION_DAYS * 86_400_000L) + queries.trimPolarEcgSegments(MAX_STORED_ECG_SEGMENTS) + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightSyncRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightSyncRepository.kt index 8cc847208..78a64e5ea 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightSyncRepository.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightSyncRepository.kt @@ -1522,6 +1522,15 @@ class SqlDelightSyncRepository( portalOrigin: Long, localSyncGeneration: Long, syncedSyncGeneration: Long, + avgHeartRateBpm: Long?, + maxHeartRateBpm: Long?, + minHeartRateBpm: Long?, + avgRrIntervalMs: Double?, + rmssdMs: Double?, + hrr60Bpm: Long?, + hrr300Bpm: Long?, + maxTorsoPitchDeg: Double?, + maxTorsoRollDeg: Double?, ): WorkoutSession = WorkoutSession( id = id, timestamp = timestamp, @@ -1575,6 +1584,15 @@ class SqlDelightSyncRepository( formScore = formScore?.toInt(), profileId = profileId, updatedAt = updatedAt, + avgHeartRateBpm = avgHeartRateBpm?.toInt(), + maxHeartRateBpm = maxHeartRateBpm?.toInt(), + minHeartRateBpm = minHeartRateBpm?.toInt(), + avgRrIntervalMs = avgRrIntervalMs?.toFloat(), + rmssdMs = rmssdMs?.toFloat(), + hrr60Bpm = hrr60Bpm?.toInt(), + hrr300Bpm = hrr300Bpm?.toInt(), + maxTorsoPitchDeg = maxTorsoPitchDeg?.toFloat(), + maxTorsoRollDeg = maxTorsoRollDeg?.toFloat(), ) // === Extended Sync Methods (GAPs 1-9) === diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt index 1b8e56d89..a8e1fa37a 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt @@ -8,6 +8,9 @@ import com.devil.phoenixproject.database.Routine as RoutineRow import com.devil.phoenixproject.domain.model.EchoLevel import com.devil.phoenixproject.domain.model.Exercise import com.devil.phoenixproject.domain.model.PRType +import com.devil.phoenixproject.domain.model.PolarBeatSample +import com.devil.phoenixproject.domain.model.PolarEcgSegment +import com.devil.phoenixproject.domain.model.PolarMetricSample import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.ScalingBasis import com.devil.phoenixproject.domain.model.RackItemBehavior @@ -122,6 +125,15 @@ class SqlDelightWorkoutRepository( portalOrigin: Long, localSyncGeneration: Long, syncedSyncGeneration: Long, + avgHeartRateBpm: Long?, + maxHeartRateBpm: Long?, + minHeartRateBpm: Long?, + avgRrIntervalMs: Double?, + rmssdMs: Double?, + hrr60Bpm: Long?, + hrr300Bpm: Long?, + maxTorsoPitchDeg: Double?, + maxTorsoRollDeg: Double?, ): WorkoutSession = WorkoutSession( id = id, timestamp = timestamp, @@ -180,6 +192,15 @@ class SqlDelightWorkoutRepository( counterweightKg = counterweightKg.toFloat(), rackItemsJson = rackItemsJson, updatedAt = updatedAt, + avgHeartRateBpm = avgHeartRateBpm?.toInt(), + maxHeartRateBpm = maxHeartRateBpm?.toInt(), + minHeartRateBpm = minHeartRateBpm?.toInt(), + avgRrIntervalMs = avgRrIntervalMs?.toFloat(), + rmssdMs = rmssdMs?.toFloat(), + hrr60Bpm = hrr60Bpm?.toInt(), + hrr300Bpm = hrr300Bpm?.toInt(), + maxTorsoPitchDeg = maxTorsoPitchDeg?.toFloat(), + maxTorsoRollDeg = maxTorsoRollDeg?.toFloat(), ) private fun mapToRoutineBasic( @@ -633,23 +654,68 @@ class SqlDelightWorkoutRepository( counterweightKg = session.counterweightKg.toDouble(), rackItemsJson = session.rackItemsJson, ) + if (session.avgHeartRateBpm != null || session.maxHeartRateBpm != null || + session.minHeartRateBpm != null || session.avgRrIntervalMs != null || + session.rmssdMs != null || session.hrr60Bpm != null || + session.hrr300Bpm != null || session.maxTorsoPitchDeg != null || + session.maxTorsoRollDeg != null + ) { + queries.updateSessionPolarSummary( + id = session.id, + avgHeartRateBpm = session.avgHeartRateBpm?.toLong(), + maxHeartRateBpm = session.maxHeartRateBpm?.toLong(), + minHeartRateBpm = session.minHeartRateBpm?.toLong(), + avgRrIntervalMs = session.avgRrIntervalMs?.toDouble(), + rmssdMs = session.rmssdMs?.toDouble(), + hrr60Bpm = session.hrr60Bpm?.toLong(), + hrr300Bpm = session.hrr300Bpm?.toLong(), + maxTorsoPitchDeg = session.maxTorsoPitchDeg?.toDouble(), + maxTorsoRollDeg = session.maxTorsoRollDeg?.toDouble(), + ) + } } private fun insertMetricRow(sessionId: String, metric: com.devil.phoenixproject.domain.model.WorkoutMetric) { // Calculate power: P = (loadA + loadB) × v (combined force × velocity for dual-cable) val power = (metric.loadA + metric.loadB) * metric.velocityA.toFloat() - queries.insertMetric( - sessionId = sessionId, - timestamp = metric.timestamp, - position = metric.positionA.toDouble(), - positionB = metric.positionB.toDouble(), - velocity = metric.velocityA, - velocityB = metric.velocityB, - load = metric.loadA.toDouble(), - loadB = metric.loadB.toDouble(), - power = power.toDouble(), - status = metric.status.toLong(), - ) + if (metric.heartRateBpm != null || metric.rrIntervalMs != null || metric.torsoPitchDeg != null || + metric.torsoRollDeg != null || metric.dynamicAccMg != null || metric.chestAccXMg != null || + metric.chestAccYMg != null || metric.chestAccZMg != null + ) { + queries.insertMetricWithPolar( + sessionId = sessionId, + timestamp = metric.timestamp, + position = metric.positionA.toDouble(), + positionB = metric.positionB.toDouble(), + velocity = metric.velocityA, + velocityB = metric.velocityB, + load = metric.loadA.toDouble(), + loadB = metric.loadB.toDouble(), + power = power.toDouble(), + status = metric.status.toLong(), + heartRateBpm = metric.heartRateBpm?.toLong(), + rrIntervalMs = metric.rrIntervalMs?.toDouble(), + torsoPitchDeg = metric.torsoPitchDeg?.toDouble(), + torsoRollDeg = metric.torsoRollDeg?.toDouble(), + dynamicAccMg = metric.dynamicAccMg?.toLong(), + chestAccXMg = metric.chestAccXMg?.toLong(), + chestAccYMg = metric.chestAccYMg?.toLong(), + chestAccZMg = metric.chestAccZMg?.toLong(), + ) + } else { + queries.insertMetric( + sessionId = sessionId, + timestamp = metric.timestamp, + position = metric.positionA.toDouble(), + positionB = metric.positionB.toDouble(), + velocity = metric.velocityA, + velocityB = metric.velocityB, + load = metric.loadA.toDouble(), + loadB = metric.loadB.toDouble(), + power = power.toDouble(), + status = metric.status.toLong(), + ) + } } override suspend fun updateSessionExerciseTag(sessionId: String, exerciseId: String, exerciseName: String) { @@ -1623,6 +1689,50 @@ class SqlDelightWorkoutRepository( withContext(Dispatchers.IO) { queries.selectExerciseIdsWithVelocityData(profileId).executeAsList() } + + override suspend fun getPolarBeatSamplesBySession(sessionId: String): List = + withContext(Dispatchers.IO) { + queries.selectPolarBeatSamplesBySession(sessionId).executeAsList().map { row -> + PolarBeatSample( + id = row.id, + sessionId = row.sessionId, + timestampMs = row.timestampMs, + phase = row.phase, + hrBpm = row.hrBpm, + rrMs = row.rrMs, + rmssdMs = row.rmssdMs, + torsoPitchDeg = row.torsoPitchDeg, + torsoRollDeg = row.torsoRollDeg, + dynamicAccMg = row.dynamicAccMg, + ) + } + } + + override suspend fun getPolarEcgSegmentsBySession(sessionId: String): List = + withContext(Dispatchers.IO) { + queries.selectPolarEcgSegmentsBySession(sessionId).executeAsList().map { row -> + PolarEcgSegment( + id = row.id, + sessionId = row.sessionId, + sensorTimestampNs = row.sensorTimestampNs, + wallClockTimestampMs = row.wallClockTimestampMs, + phase = row.phase, + samplesUvCsv = row.samplesUvCsv, + ) + } + } + + override suspend fun getMetricSamplesBySession(sessionId: String): List = + withContext(Dispatchers.IO) { + queries.selectMetricsBySession(sessionId).executeAsList().map { row -> + PolarMetricSample( + position = row.position, + positionB = row.positionB, + torsoPitchDeg = row.torsoPitchDeg, + torsoRollDeg = row.torsoRollDeg, + ) + } + } } /** Name given to a healed routine exercise whose stored name and id are both blank (#774). */ diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/WorkoutRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/WorkoutRepository.kt index 87a40687a..8fba4bac4 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/WorkoutRepository.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/WorkoutRepository.kt @@ -2,6 +2,9 @@ package com.devil.phoenixproject.data.repository import com.devil.phoenixproject.domain.model.BiomechanicsRepResult import com.devil.phoenixproject.domain.model.CompletedSet +import com.devil.phoenixproject.domain.model.PolarBeatSample +import com.devil.phoenixproject.domain.model.PolarEcgSegment +import com.devil.phoenixproject.domain.model.PolarMetricSample import com.devil.phoenixproject.domain.model.RepMetricData import com.devil.phoenixproject.domain.model.Routine import com.devil.phoenixproject.domain.model.RoutineGroup @@ -246,6 +249,15 @@ interface WorkoutRepository { * ready to estimate. */ suspend fun getExerciseIdsWithVelocityData(profileId: String): List + + /** Historical Polar beat samples for a session. */ + suspend fun getPolarBeatSamplesBySession(sessionId: String): List + + /** Historical Polar ECG segments for a session. */ + suspend fun getPolarEcgSegmentsBySession(sessionId: String): List + + /** Historical torso and cable position samples for a session. */ + suspend fun getMetricSamplesBySession(sessionId: String): List } /** A routine a CSV import was going to overwrite was deleted or moved to another profile (#772). */ diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt index 07bf81739..b5f02c7ed 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt @@ -2,6 +2,7 @@ package com.devil.phoenixproject.di import app.cash.sqldelight.db.SqlDriver import com.devil.phoenixproject.data.csv.CsvImportInbox +import com.devil.phoenixproject.data.ble.PolarH10BleManager import com.devil.phoenixproject.data.integration.ExternalActivityRepository import com.devil.phoenixproject.data.integration.ExternalExerciseTemplateRepository import com.devil.phoenixproject.data.integration.ExternalMeasurementRepository @@ -55,6 +56,7 @@ val dataModule = module { // Order matters: ExerciseRepository must be created before WorkoutRepository single { SqlDelightExerciseRepository(get(), get(), get()) } single { SqlDelightProfileExerciseBaselineRepository(get()) } + single { SqlDelightPolarTelemetryRepository(get()) } single { LegacyBaselineRepair(get()) } single { ProfileMutationBarrier() } single { ProfileRecoveryActivityTracker() } @@ -169,4 +171,7 @@ val dataModule = module { single { SqlDelightExternalExerciseTemplateRepository(get()) } single { SqlDelightIntegrationSyncCursorRepository(get()) } single { HealthBackfillManager(get(), get(), get(), get()) } + + // Polar H10 BLE Manager + single { PolarH10BleManager(settings = get(), telemetryRepository = get()) } onClose { (it as? PolarH10BleManager)?.shutdown() } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt index c1c883b34..8c2c74ee4 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt @@ -419,6 +419,15 @@ data class WorkoutMetric( val velocityA: Double = 0.0, // Firmware-provided velocity used for handle detection val velocityB: Double = 0.0, // Velocity for right handle detection (for single-handle exercises) val status: Int = 0, // Machine status flags (0x8000=Deload Occurred, 0x0040=Deload Warn) + // Polar H10 intra-set synchronized telemetry & torso biomechanics (migration 59) + val heartRateBpm: Int? = null, + val rrIntervalMs: Float? = null, + val torsoPitchDeg: Float? = null, + val torsoRollDeg: Float? = null, + val dynamicAccMg: Int? = null, + val chestAccXMg: Int? = null, + val chestAccYMg: Int? = null, + val chestAccZMg: Int? = null, ) { val totalLoad: Float get() = loadA + loadB } @@ -620,6 +629,16 @@ data class WorkoutSession( val profileId: String = "default", /** Domain last-edit epoch millis used as the portal LWW wire timestamp. */ val updatedAt: Long? = null, + // Polar H10 cardiac & torso biomechanics summary (added in migration 59) + val avgHeartRateBpm: Int? = null, + val maxHeartRateBpm: Int? = null, + val minHeartRateBpm: Int? = null, + val avgRrIntervalMs: Float? = null, + val rmssdMs: Float? = null, + val hrr60Bpm: Int? = null, + val hrr300Bpm: Int? = null, + val maxTorsoPitchDeg: Float? = null, + val maxTorsoRollDeg: Float? = null, ) { /** True if this session has detailed summary metrics (v0.2.1+) */ val hasSummaryMetrics: Boolean diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/PolarModels.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/PolarModels.kt new file mode 100644 index 000000000..d8f8ba233 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/PolarModels.kt @@ -0,0 +1,67 @@ +package com.devil.phoenixproject.domain.model + +/** + * Standard Bluetooth SIG Heart Rate Service (0x180D / 0x2A37) measurement. + * + * @param hr Heart rate in beats per minute (BPM). + * @param contactDetected True if skin contact is actively detected by the sensor. + * @param energyExpendedKj Optional accumulated energy expended in kiloJoules. + * @param rrIntervalsMs R-R peak intervals in milliseconds, converted from 1/1024s resolution. + */ +data class PolarHrMeasurement( + val hr: Int, + val contactDetected: Boolean, + val energyExpendedKj: Int? = null, + val rrIntervalsMs: List = emptyList(), +) + +/** + * R-R interval heart beat event with accurate timestamp derived from the chained RR-interval clock. + * + * @param rrIntervalMs R-R interval duration in milliseconds. + * @param timestampMs Reconstructed beat timestamp in epoch milliseconds. + * @param hr Instantaneous heart rate in BPM at this beat. + */ +data class PolarRrBeat( + val rrIntervalMs: Double, + val timestampMs: Long, + val hr: Int = 0, +) + +/** + * Tri-axial acceleration sample in milli-G (mG). + */ +data class PolarAccSample( + val xMg: Int, + val yMg: Int, + val zMg: Int, +) + +/** + * Polar Measurement Data (PMD) stream packet received on characteristic FB005C82. + */ +sealed class PolarPmdMeasurement { + abstract val sensorTimestampNs: Long + + /** + * Electrocardiogram (ECG) data frame (130 Hz, 14-bit resolution). + * + * @param sensorTimestampNs Sensor internal clock in nanoseconds. + * @param samplesMicrovolts ECG voltage samples in microvolts (µV). + */ + data class Ecg( + override val sensorTimestampNs: Long, + val samplesMicrovolts: List, + ) : PolarPmdMeasurement() + + /** + * Accelerometer data frame (typically 50 Hz, 16-bit resolution, ±8G range). + * + * @param sensorTimestampNs Sensor internal clock in nanoseconds. + * @param samples List of 3-axis acceleration samples in milli-G. + */ + data class Acc( + override val sensorTimestampNs: Long, + val samples: List, + ) : PolarPmdMeasurement() +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/PolarTelemetryRecords.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/PolarTelemetryRecords.kt new file mode 100644 index 000000000..74eb6498f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/PolarTelemetryRecords.kt @@ -0,0 +1,35 @@ +package com.devil.phoenixproject.domain.model + +data class PolarBeatSample( + val id: Long, + val sessionId: String, + val timestampMs: Long, + val phase: String, + val hrBpm: Long, + val rrMs: Double, + val rmssdMs: Double?, + val torsoPitchDeg: Double?, + val torsoRollDeg: Double?, + val dynamicAccMg: Long?, +) + +data class PolarEcgSegment( + val id: Long, + val sessionId: String, + val sensorTimestampNs: Long, + val wallClockTimestampMs: Long, + val phase: String, + val samplesUvCsv: String, +) + +data class PolarMetricSample( + val position: Double?, + val positionB: Double?, + val torsoPitchDeg: Double?, + val torsoRollDeg: Double?, +) + +data class PolarRecovery( + val hrr60Bpm: Int?, + val hrr300Bpm: Int?, +) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt index 70ed998e2..20e200fb9 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt @@ -1,6 +1,7 @@ package com.devil.phoenixproject.presentation.manager import co.touchlab.kermit.Logger +import com.devil.phoenixproject.data.ble.PolarH10BleManager import com.devil.phoenixproject.data.integration.ExternalActivityRepository import com.devil.phoenixproject.data.integration.HealthExportMarkers import com.devil.phoenixproject.data.integration.HealthIntegration @@ -571,6 +572,7 @@ class ActiveSessionEngine( private val healthExportCursorRepository: IntegrationSyncCursorRepository? = null, private val elapsedRealtimeProvider: () -> Long = ::elapsedRealtimeMillis, private val wallClockMillisProvider: () -> Long = ::currentTimeMillis, + private val polarBleManager: PolarH10BleManager? = null, ) { private data class PendingRestRackSelection( val itemIds: List, @@ -5977,10 +5979,25 @@ class ActiveSessionEngine( } /** - * Collect metric for history recording. + * Collect metric for history recording, stamping live Polar H10 cardiac & torso biomechanics. */ private fun collectMetricForHistory(metric: WorkoutMetric) { - coordinator.collectedMetrics.append(metric) + val stampedMetric = polarBleManager?.let { polar -> + if (!polar.isConnected.value) return@let null + val hr = polar.hrMeasurement.value + val lastAcc = polar.latestAcc.value?.samples?.lastOrNull() + metric.copy( + heartRateBpm = hr?.hr, + rrIntervalMs = hr?.rrIntervalsMs?.lastOrNull()?.toFloat(), + torsoPitchDeg = polar.torsoPitchDeg.value?.toFloat(), + torsoRollDeg = polar.torsoRollDeg.value?.toFloat(), + dynamicAccMg = polar.dynamicAccMg.value, + chestAccXMg = lastAcc?.xMg, + chestAccYMg = lastAcc?.yMg, + chestAccZMg = lastAcc?.zMg, + ) + } ?: metric + coordinator.collectedMetrics.append(stampedMetric) } // ===== Auto-Stop Helpers ===== @@ -6670,6 +6687,7 @@ class ActiveSessionEngine( coordinator._repCount.value.workingReps == 0 ) { coordinator.warmupCompleteTimeMs = metric.timestamp + polarBleManager?.updateSessionPhase("WORKING_SET") // F-022: start working rep 1's window at the end of warmup. Without a // boundary here the window reaches back to set start, so rep 1's mean // concentric velocity - the baseline for velocity-loss auto-end and for @@ -8078,6 +8096,10 @@ class ActiveSessionEngine( return null } bodyweightCompletionGate.beginExecution(lease) + polarBleManager?.startSessionRecording( + sessionId = lease.sessionId, + phase = if (coordinator._repCount.value.warmupReps > 0 && !coordinator._repCount.value.isWarmupComplete) "WARMUP" else "WORKING_SET", + ) if (!installBiomechanicsContext(lease)) return null retryRetainedWorkoutExitPersistence() executionContext = null @@ -9282,6 +9304,7 @@ class ActiveSessionEngine( // window) is not a measurement and must never be read back as achieved load. // Shared rule with the manual-stop path: [recordedHeaviestKg]. val recordedHeaviestKgPerCable = summary.recordedHeaviestKg(params.isEchoMode) + val polarSummary = polarBleManager?.freezeSessionRecording(lease.sessionId) val session = WorkoutSession( id = lease.sessionId, timestamp = timing.startMs, @@ -9329,6 +9352,15 @@ class ActiveSessionEngine( dominantSide = biomechanicsSummary?.dominantSide, strengthProfile = biomechanicsSummary?.strengthProfile?.name, profileId = lease.profileId, + avgHeartRateBpm = polarSummary?.avgHeartRateBpm, + maxHeartRateBpm = polarSummary?.maxHeartRateBpm, + minHeartRateBpm = polarSummary?.minHeartRateBpm, + avgRrIntervalMs = polarSummary?.avgRrIntervalMs, + rmssdMs = polarSummary?.rmssdMs, + hrr60Bpm = polarSummary?.hrr60Bpm, + hrr300Bpm = polarSummary?.hrr300Bpm, + maxTorsoPitchDeg = polarSummary?.maxTorsoPitchDeg, + maxTorsoRollDeg = polarSummary?.maxTorsoRollDeg, ) val hasExerciseIdentity = params.selectedExerciseId != null || params.isJustLift val persistZeroRepStallAttempt = completion.reason == SetEndReason.STALL_FAILURE && @@ -9539,6 +9571,7 @@ class ActiveSessionEngine( repMetrics = snapshot.repMetrics, repBiomechanics = snapshot.biomechanicsRepResults, ) + polarBleManager?.flushSessionTelemetry(sessionId)?.join() // Issue #869: the set is durable; Set Summary may now appear. commitSettled?.complete(Unit) snapshot.singleExerciseDefaults?.let { defaults -> @@ -10595,6 +10628,14 @@ class ActiveSessionEngine( val startAtStopMs = coordinator.workoutStartTime val warmupCompleteAtStopMs = coordinator.warmupCompleteTimeMs val firstSampleAtStopMs = coordinator.collectedMetrics.snapshot().firstOrNull()?.timestamp + val polarSessionIdAtStop = polarBleManager?.currentRecordingSessionId + val polarSummaryAtStop = if (manualSnapshot == null) { + polarSessionIdAtStop?.let { sessionId -> + polarBleManager?.freezeSessionRecording(sessionId) + } + } else { + null + } val completeStop: () -> Unit = { launchPresentationContinuation(lease) { @@ -10785,6 +10826,15 @@ class ActiveSessionEngine( totalVelocityLossPercent = bioSummary?.totalVelocityLossPercent, dominantSide = bioSummary?.dominantSide, strengthProfile = bioSummary?.strengthProfile?.name, + avgHeartRateBpm = polarSummaryAtStop?.avgHeartRateBpm, + maxHeartRateBpm = polarSummaryAtStop?.maxHeartRateBpm, + minHeartRateBpm = polarSummaryAtStop?.minHeartRateBpm, + avgRrIntervalMs = polarSummaryAtStop?.avgRrIntervalMs, + rmssdMs = polarSummaryAtStop?.rmssdMs, + hrr60Bpm = polarSummaryAtStop?.hrr60Bpm, + hrr300Bpm = polarSummaryAtStop?.hrr300Bpm, + maxTorsoPitchDeg = polarSummaryAtStop?.maxTorsoPitchDeg, + maxTorsoRollDeg = polarSummaryAtStop?.maxTorsoRollDeg, // C4: profileId was missing from this path. FP-6: it is the owning // profile, not the live one. profileId = attributionProfileId, @@ -10827,6 +10877,9 @@ class ActiveSessionEngine( repMetrics = emptyList(), repBiomechanics = emptyList(), ) + polarSessionIdAtStop?.let { recordedSourceId -> + polarBleManager?.flushSessionTelemetry(session.id, recordedSourceId)?.join() + } val completedSetId = completedSet?.id if (completedSet != null) { Logger.d( diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt index 7e6940fc5..2142754ea 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt @@ -1,6 +1,7 @@ package com.devil.phoenixproject.presentation.manager import co.touchlab.kermit.Logger +import com.devil.phoenixproject.data.ble.PolarH10BleManager import com.devil.phoenixproject.data.integration.ExternalActivityRepository import com.devil.phoenixproject.data.integration.HealthIntegration import com.devil.phoenixproject.data.integration.IntegrationSyncCursorRepository @@ -258,6 +259,7 @@ class DefaultWorkoutSessionManager( private val scope: CoroutineScope, private val machineSafetyCoordinator: MachineSafetyCoordinator? = null, private val profileRecoveryActivityTracker: ProfileRecoveryActivityTracker? = null, + private val polarBleManager: PolarH10BleManager? = null, private val recentJustLiftExerciseStore: RecentJustLiftExerciseStore? = null, private val biomechanicsDispatcher: CoroutineDispatcher = Dispatchers.Default, private val biomechanicsRepProcessor: BiomechanicsRepProcessor = BiomechanicsRepProcessor.Default, @@ -431,6 +433,7 @@ class DefaultWorkoutSessionManager( healthExportCursorRepository = healthExportCursorRepository, elapsedRealtimeProvider = elapsedRealtimeProvider, wallClockMillisProvider = wallClockMillisProvider, + polarBleManager = polarBleManager, ) companion object { @@ -515,6 +518,58 @@ class DefaultWorkoutSessionManager( } } + if (polarBleManager != null) { + scope.launch { + try { + coordinator.workoutState.collect { state -> + when (state) { + is WorkoutState.Resting -> { + polarBleManager.updateSessionPhase("REST") + } + is WorkoutState.Active -> { + val repCount = coordinator.repCount.value + val phase = if (repCount.warmupReps > 0 && !repCount.isWarmupComplete) "WARMUP" else "WORKING_SET" + polarBleManager.updateSessionPhase(phase) + } + is WorkoutState.Completed, WorkoutState.RoutineComplete -> { + if (polarBleManager.isConnected.value) { + polarBleManager.endSessionRecording() + } + } + is WorkoutState.SetSummary -> { + polarBleManager.updateSessionPhase("REST") + } + is WorkoutState.Idle -> { + if (!polarBleManager.isCooldownActive.value && + coordinator.routineFlowState.value !is RoutineFlowState.Complete) { + polarBleManager.discardActiveRecording() + } + } + else -> {} + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Logger.e(e) { "Error in Polar session phase collector" } + } + } + + scope.launch { + try { + coordinator.routineFlowState.collect { flowState -> + if (flowState is RoutineFlowState.Complete) { + if (polarBleManager.isConnected.value) { + polarBleManager.endSessionRecording() + } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Logger.e(e) { "Error in Polar routine flow collector" } + } + } + } + scope.launch { try { settingsManager.userPreferences diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ConnectionLogsViewModel.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ConnectionLogsViewModel.kt index 1c6b4112c..91e2f8f8b 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ConnectionLogsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ConnectionLogsViewModel.kt @@ -20,6 +20,7 @@ data class LogFilter( val showInfo: Boolean = true, val showWarning: Boolean = true, val showError: Boolean = true, + val hrmOnly: Boolean = false, val searchQuery: String = "", ) @@ -53,6 +54,13 @@ class ConnectionLogsViewModel : ViewModel() { else -> true } + val isHrm = log.tag == "HRM" || + log.eventType.startsWith("HRM_") || + (log.deviceName?.contains("Polar", ignoreCase = true) == true) || + log.message.startsWith("[HRM]") + + val hrmMatch = if (filter.hrmOnly) isHrm else true + val searchMatch = if (filter.searchQuery.isBlank()) { true } else { @@ -62,7 +70,7 @@ class ConnectionLogsViewModel : ViewModel() { (log.details?.contains(filter.searchQuery, ignoreCase = true) == true) } - levelMatch && searchMatch + levelMatch && hrmMatch && searchMatch } }.stateIn( viewModelScope, @@ -84,6 +92,13 @@ class ConnectionLogsViewModel : ViewModel() { } } + /** + * Toggle HRM chest strap filter. + */ + fun toggleHrmOnly() { + _filter.value = _filter.value.copy(hrmOnly = !_filter.value.hrmOnly) + } + /** * Set the search query. */ diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt index 2f37a0178..7ddf9fbc7 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt @@ -3,6 +3,7 @@ package com.devil.phoenixproject.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import com.devil.phoenixproject.data.ble.PolarH10BleManager import com.devil.phoenixproject.data.integration.ExternalActivityRepository import com.devil.phoenixproject.data.integration.HealthIntegration import com.devil.phoenixproject.data.integration.IntegrationSyncCursorRepository @@ -501,7 +502,7 @@ internal fun routineResumeDiscardUiDecision( class MainViewModel( private val bleRepository: BleRepository, - private val workoutRepository: WorkoutRepository, + val workoutRepository: WorkoutRepository, val exerciseRepository: ExerciseRepository, val personalRecordRepository: PersonalRecordRepository, private val profileExerciseBaselineRepository: ProfileExerciseBaselineRepository, @@ -543,6 +544,7 @@ class MainViewModel( internal val machineSafetyCoordinator: MachineSafetyCoordinator, private val profileRecoveryActivityTracker: ProfileRecoveryActivityTracker? = null, private val recentJustLiftExerciseStore: RecentJustLiftExerciseStore? = null, + val polarBleManager: PolarH10BleManager? = null, ) : ViewModel() { // Shared haptic events flow - created here, passed to both GamificationManager and WorkoutSessionManager @@ -732,6 +734,7 @@ class MainViewModel( machineSafetyCoordinator = machineSafetyCoordinator, profileRecoveryActivityTracker = profileRecoveryActivityTracker, recentJustLiftExerciseStore = recentJustLiftExerciseStore, + polarBleManager = polarBleManager, ) // === Phase 2a: BleConnectionManager (extracted from this class) === @@ -1458,6 +1461,12 @@ class MainViewModel( } catch (e: Exception) { Logger.e { "Failed to disconnect BLE during cleanup: ${e.message}" } } + try { + polarBleManager?.disconnect() + Logger.i { "Polar BLE disconnected during ViewModel cleanup" } + } catch (e: Exception) { + Logger.e { "Failed to disconnect Polar BLE during cleanup: ${e.message}" } + } Logger.i { "MainViewModel cleared, all jobs cancelled" } } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt index 64d906bd2..43944db62 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt @@ -94,6 +94,16 @@ data class WorkoutSessionBackup( * because a post-sync edit keeps its stamp while the row is dirty. */ val syncAcknowledged: Boolean = false, + // Polar H10 cardiac & torso biomechanics summary (added in migration 59) + val avgHeartRateBpm: Int? = null, + val maxHeartRateBpm: Int? = null, + val minHeartRateBpm: Int? = null, + val avgRrIntervalMs: Float? = null, + val rmssdMs: Float? = null, + val hrr60Bpm: Int? = null, + val hrr300Bpm: Int? = null, + val maxTorsoPitchDeg: Float? = null, + val maxTorsoRollDeg: Float? = null, ) /** @@ -112,6 +122,45 @@ data class MetricSampleBackup( val loadB: Float? = null, val power: Float?, val status: Int = 0, + // Polar H10 intra-set synchronized telemetry & torso biomechanics (added in migration 59) + val heartRateBpm: Int? = null, + val rrIntervalMs: Float? = null, + val torsoPitchDeg: Float? = null, + val torsoRollDeg: Float? = null, + val dynamicAccMg: Int? = null, + val chestAccXMg: Int? = null, + val chestAccYMg: Int? = null, + val chestAccZMg: Int? = null, +) + +/** + * Backup representation of PolarBeatSample + */ +@Serializable +data class PolarBeatSampleBackup( + val id: Long = 0, + val sessionId: String, + val timestampMs: Long, + val phase: String, + val hrBpm: Int, + val rrMs: Float, + val rmssdMs: Float? = null, + val torsoPitchDeg: Float? = null, + val torsoRollDeg: Float? = null, + val dynamicAccMg: Int? = null, +) + +/** + * Backup representation of PolarEcgSegment + */ +@Serializable +data class PolarEcgSegmentBackup( + val id: Long = 0, + val sessionId: String, + val sensorTimestampNs: Long, + val wallClockTimestampMs: Long, + val phase: String, + val samplesUvCsv: String, ) /** @@ -656,7 +705,7 @@ fun backupPrivacySummary(includesRawTelemetry: Boolean): String = "profiles, profile training preferences, training baselines, personal records, badges and stats, and notes. " + ( if (includesRawTelemetry) { - "Includes raw per-sample force and position telemetry. " + "Includes raw per-sample force, position, heart rate, RR, ECG, and accelerometer telemetry. " } else { "Does not include raw per-sample telemetry (turn on \"Include raw telemetry\" to add it). " } @@ -734,6 +783,9 @@ data class BackupContent( val localOwnershipClaims: List = emptyList(), val cycleConflictDrafts: List = emptyList(), val cycleSyncStates: List = emptyList(), + // Added migration 58: Polar H10 multi-tier continuous timeline and ECG traces + val polarBeatSamples: List = emptyList(), + val polarEcgSegments: List = emptyList(), ) /** @@ -799,6 +851,10 @@ data class ImportResult( val cycleConflictDraftsSkipped: Int = 0, val cycleSyncStatesImported: Int = 0, val cycleSyncStatesSkipped: Int = 0, + val polarBeatSamplesImported: Int = 0, + val polarBeatSamplesSkipped: Int = 0, + val polarEcgSegmentsImported: Int = 0, + val polarEcgSegmentsSkipped: Int = 0, val repairedReferences: Int = 0, /** Rows of a profile the user permanently deleted (PR 20), left out on purpose. */ val deletedProfileRowsSkipped: Int = 0, @@ -827,7 +883,8 @@ data class ImportResult( profileExerciseBaselinesImported + workoutDeletionsImported + pendingProfileRecoveriesImported + ownershipTransfersImported + appliedOwnershipEventsImported + localOwnershipClaimsImported + - cycleConflictDraftsImported + cycleSyncStatesImported + cycleConflictDraftsImported + cycleSyncStatesImported + + polarBeatSamplesImported + polarEcgSegmentsImported val totalSkipped: Int get() = sessionsSkipped + metricsSkipped + routinesSkipped + supersetsSkipped + personalRecordsSkipped + @@ -839,5 +896,6 @@ data class ImportResult( profileExerciseBaselinesSkipped + workoutDeletionsSkipped + pendingProfileRecoveriesSkipped + ownershipTransfersSkipped + appliedOwnershipEventsSkipped + cycleConflictDraftsSkipped + - localOwnershipClaimsSkipped + cycleSyncStatesSkipped + deletedProfileRowsSkipped + localOwnershipClaimsSkipped + cycleSyncStatesSkipped + + polarBeatSamplesSkipped + polarEcgSegmentsSkipped + deletedProfileRowsSkipped } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt index 8c576298e..e0035dad0 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt @@ -166,4 +166,49 @@ object BleConstants { val versionCharacteristic = characteristicOf(service = NUS_SERVICE_UUID, characteristic = VERSION_UUID) val modeCharacteristic = characteristicOf(service = NUS_SERVICE_UUID, characteristic = MODE_UUID) val firmwareRevisionCharacteristic = characteristicOf(service = DIS_SERVICE_UUID, characteristic = FIRMWARE_REVISION_UUID) + + // ------------------------------------------------------------------------- + // Polar H10 GATT constants & commands + // ------------------------------------------------------------------------- + object Polar { + const val HR_SERVICE_UUID_STRING = "0000180d-0000-1000-8000-00805f9b34fb" + const val HR_MEASUREMENT_CHAR_UUID_STRING = "00002a37-0000-1000-8000-00805f9b34fb" + const val BATTERY_SERVICE_UUID_STRING = "0000180f-0000-1000-8000-00805f9b34fb" + const val BATTERY_LEVEL_CHAR_UUID_STRING = "00002a19-0000-1000-8000-00805f9b34fb" + const val PMD_SERVICE_UUID_STRING = "fb005c80-02e7-f387-1cad-8acd2d8df0c8" + const val PMD_CONTROL_POINT_CHAR_UUID_STRING = "fb005c81-02e7-f387-1cad-8acd2d8df0c8" + const val PMD_DATA_CHAR_UUID_STRING = "fb005c82-02e7-f387-1cad-8acd2d8df0c8" + + val HR_SERVICE_UUID = Uuid.parse(HR_SERVICE_UUID_STRING) + val HR_MEASUREMENT_UUID = Uuid.parse(HR_MEASUREMENT_CHAR_UUID_STRING) + val BATTERY_SERVICE_UUID = Uuid.parse(BATTERY_SERVICE_UUID_STRING) + val BATTERY_LEVEL_UUID = Uuid.parse(BATTERY_LEVEL_CHAR_UUID_STRING) + val PMD_SERVICE_UUID = Uuid.parse(PMD_SERVICE_UUID_STRING) + val PMD_CONTROL_POINT_UUID = Uuid.parse(PMD_CONTROL_POINT_CHAR_UUID_STRING) + val PMD_DATA_UUID = Uuid.parse(PMD_DATA_CHAR_UUID_STRING) + + val hrMeasurementCharacteristic = characteristicOf(service = HR_SERVICE_UUID, characteristic = HR_MEASUREMENT_UUID) + val batteryLevelCharacteristic = characteristicOf(service = BATTERY_SERVICE_UUID, characteristic = BATTERY_LEVEL_UUID) + val pmdControlPointCharacteristic = characteristicOf(service = PMD_SERVICE_UUID, characteristic = PMD_CONTROL_POINT_UUID) + val pmdDataCharacteristic = characteristicOf(service = PMD_SERVICE_UUID, characteristic = PMD_DATA_UUID) + + const val DESIRED_PMD_MTU = 232 + + private object Commands { + val START_ECG = byteArrayOf(0x02, 0x00, 0x00, 0x01, 0x82.toByte(), 0x00, 0x01, 0x01, 0x0E, 0x00) + val START_ACC = byteArrayOf(0x02, 0x02, 0x02, 0x01, 0x08, 0x00, 0x00, 0x01, 0x32, 0x00, 0x01, 0x01, 0x10, 0x00) + val START_ACC_ASCENDING = byteArrayOf(0x02, 0x02, 0x00, 0x01, 0x32, 0x00, 0x01, 0x01, 0x10, 0x00, 0x02, 0x01, 0x08, 0x00) + val START_ACC_FALLBACK_25HZ = byteArrayOf(0x02, 0x02, 0x00, 0x01, 0x19, 0x00, 0x01, 0x01, 0x10, 0x00, 0x02, 0x01, 0x02, 0x00) + val STOP_ECG = byteArrayOf(0x03, 0x00) + val STOP_ACC = byteArrayOf(0x03, 0x02) + } + + val PMD_START_ECG_CMD: ByteArray get() = Commands.START_ECG.copyOf() + val PMD_START_ACC_CMD: ByteArray get() = Commands.START_ACC.copyOf() + val PMD_START_ACC_ASCENDING_CMD: ByteArray get() = Commands.START_ACC_ASCENDING.copyOf() + val PMD_START_ACC_FALLBACK_25HZ_CMD: ByteArray get() = Commands.START_ACC_FALLBACK_25HZ.copyOf() + val PMD_STOP_ECG_CMD: ByteArray get() = Commands.STOP_ECG.copyOf() + val PMD_STOP_ACC_CMD: ByteArray get() = Commands.STOP_ACC.copyOf() + } } + diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt index 2353c3cdb..c80be6205 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt @@ -37,6 +37,8 @@ import com.devil.phoenixproject.database.StreakHistory import com.devil.phoenixproject.database.Superset import com.devil.phoenixproject.database.TrainingCycle import com.devil.phoenixproject.database.UserProfile +import com.devil.phoenixproject.database.PolarBeatSample +import com.devil.phoenixproject.database.PolarEcgSegment import com.devil.phoenixproject.database.PhoenixDatabase import com.devil.phoenixproject.database.WorkoutSession import com.devil.phoenixproject.database.WorkoutDeletion @@ -308,6 +310,8 @@ abstract class BaseDataBackupManager( "workoutSessions", "metricSamples", "completedSets", + "polarBeatSamples", + "polarEcgSegments", "personalRecords", "progressionEvents", "earnedBadges", @@ -340,6 +344,8 @@ abstract class BaseDataBackupManager( "workoutSessions", "metricSamples", "completedSets", + "polarBeatSamples", + "polarEcgSegments", "personalRecords", // After every section that names exercises (routine exercises, sessions, PRs): a // baseline row carries only an id, so a retired catalogue id that maps by name alone @@ -438,10 +444,14 @@ abstract class BaseDataBackupManager( // Loading all metrics at once can cause OOM crashes on iOS due to how the // native SQLite driver handles large result sets. val metrics = mutableListOf() + val polarBeatSamples = mutableListOf() + val polarEcgSegments = mutableListOf() if (includeRawTelemetryInBackups) { for (session in sessions) { val sessionMetrics = queries.selectMetricsBySession(session.id).executeAsList() metrics.addAll(sessionMetrics) + polarBeatSamples.addAll(queries.selectPolarBeatSamplesBySession(session.id).executeAsList()) + polarEcgSegments.addAll(queries.selectPolarEcgSegmentsBySession(session.id).executeAsList()) } } @@ -546,6 +556,8 @@ abstract class BaseDataBackupManager( localOwnershipClaims = localOwnershipClaims.map(::mapLocalOwnershipClaimToBackup), cycleSyncStates = cycleSyncStates.map(::mapCycleSyncStateToBackup), cycleConflictDrafts = cycleConflictDrafts.map(::mapCycleConflictDraftToBackup), + polarBeatSamples = polarBeatSamples.map(::mapPolarBeatSampleToBackup), + polarEcgSegments = polarEcgSegments.map(::mapPolarEcgSegmentToBackup), ), ) } @@ -786,6 +798,8 @@ abstract class BaseDataBackupManager( "workoutSessions" -> json.decodeFromString(raw) "metricSamples" -> json.decodeFromString(raw) "completedSets" -> json.decodeFromString(raw) + "polarBeatSamples" -> json.decodeFromString(raw) + "polarEcgSegments" -> json.decodeFromString(raw) "personalRecords" -> json.decodeFromString(raw) "progressionEvents" -> json.decodeFromString(raw) "earnedBadges" -> json.decodeFromString(raw) @@ -893,6 +907,10 @@ abstract class BaseDataBackupManager( var plannedSetsSkipped = 0 var completedSetsImported = 0 var completedSetsSkipped = 0 + var polarBeatSamplesImported = 0 + var polarBeatSamplesSkipped = 0 + var polarEcgSegmentsImported = 0 + var polarEcgSegmentsSkipped = 0 var progressionEventsImported = 0 var progressionEventsSkipped = 0 var earnedBadgesImported = 0 @@ -1059,18 +1077,45 @@ abstract class BaseDataBackupManager( Logger.w { "Backup restore cannot safely retry legacy metric without stable id for session ${metric.sessionId}" } return } - queries.insertMetric( - sessionId = metric.sessionId, - timestamp = metric.timestamp, - position = metric.position?.toDouble(), - positionB = metric.positionB?.toDouble(), - velocity = metric.velocity?.toDouble(), - velocityB = metric.velocityB?.toDouble(), - load = metric.load?.toDouble(), - loadB = metric.loadB?.toDouble(), - power = metric.power?.toDouble(), - status = metric.status.toLong(), - ) + if (metric.heartRateBpm != null || metric.rrIntervalMs != null || + metric.torsoPitchDeg != null || metric.torsoRollDeg != null || + metric.dynamicAccMg != null || metric.chestAccXMg != null || + metric.chestAccYMg != null || metric.chestAccZMg != null + ) { + queries.insertMetricWithPolar( + sessionId = metric.sessionId, + timestamp = metric.timestamp, + position = metric.position?.toDouble(), + positionB = metric.positionB?.toDouble(), + velocity = metric.velocity?.toDouble(), + velocityB = metric.velocityB?.toDouble(), + load = metric.load?.toDouble(), + loadB = metric.loadB?.toDouble(), + power = metric.power?.toDouble(), + status = metric.status.toLong(), + heartRateBpm = metric.heartRateBpm?.toLong(), + rrIntervalMs = metric.rrIntervalMs?.toDouble(), + torsoPitchDeg = metric.torsoPitchDeg?.toDouble(), + torsoRollDeg = metric.torsoRollDeg?.toDouble(), + dynamicAccMg = metric.dynamicAccMg?.toLong(), + chestAccXMg = metric.chestAccXMg?.toLong(), + chestAccYMg = metric.chestAccYMg?.toLong(), + chestAccZMg = metric.chestAccZMg?.toLong(), + ) + } else { + queries.insertMetric( + sessionId = metric.sessionId, + timestamp = metric.timestamp, + position = metric.position?.toDouble(), + positionB = metric.positionB?.toDouble(), + velocity = metric.velocity?.toDouble(), + velocityB = metric.velocityB?.toDouble(), + load = metric.load?.toDouble(), + loadB = metric.loadB?.toDouble(), + power = metric.power?.toDouble(), + status = metric.status.toLong(), + ) + } queries.markWorkoutComponentDirty(metric.sessionId) holdGroupOfRestoredChild(metric.sessionId) metricsImported++ @@ -1082,7 +1127,15 @@ abstract class BaseDataBackupManager( existing.position?.toFloat() == metric.position && existing.positionB?.toFloat() == metric.positionB && existing.velocity?.toFloat() == metric.velocity && existing.velocityB?.toFloat() == metric.velocityB && existing.load?.toFloat() == metric.load && existing.loadB?.toFloat() == metric.loadB && - existing.power?.toFloat() == metric.power && existing.status == metric.status.toLong() + existing.power?.toFloat() == metric.power && existing.status == metric.status.toLong() && + existing.heartRateBpm?.toInt() == metric.heartRateBpm && + existing.rrIntervalMs?.toFloat() == metric.rrIntervalMs && + existing.torsoPitchDeg?.toFloat() == metric.torsoPitchDeg && + existing.torsoRollDeg?.toFloat() == metric.torsoRollDeg && + existing.dynamicAccMg?.toInt() == metric.dynamicAccMg && + existing.chestAccXMg?.toInt() == metric.chestAccXMg && + existing.chestAccYMg?.toInt() == metric.chestAccYMg && + existing.chestAccZMg?.toInt() == metric.chestAccZMg if (same) metricsSkipped++ else { entitiesWithErrors++ Logger.w { "Backup restore conflict metric id=${metric.id}" } @@ -1101,6 +1154,14 @@ abstract class BaseDataBackupManager( loadB = metric.loadB?.toDouble(), power = metric.power?.toDouble(), status = metric.status.toLong(), + heartRateBpm = metric.heartRateBpm?.toLong(), + rrIntervalMs = metric.rrIntervalMs?.toDouble(), + torsoPitchDeg = metric.torsoPitchDeg?.toDouble(), + torsoRollDeg = metric.torsoRollDeg?.toDouble(), + dynamicAccMg = metric.dynamicAccMg?.toLong(), + chestAccXMg = metric.chestAccXMg?.toLong(), + chestAccYMg = metric.chestAccYMg?.toLong(), + chestAccZMg = metric.chestAccZMg?.toLong(), ) queries.markWorkoutComponentDirty(metric.sessionId) holdGroupOfRestoredChild(metric.sessionId) @@ -1612,6 +1673,25 @@ abstract class BaseDataBackupManager( resolvedRoutineSessionId?.let { groupId -> restoredRoutineGroupIds.getOrPut(sessionProfileId) { linkedSetOf() } += groupId } + if (session.avgHeartRateBpm != null || session.maxHeartRateBpm != null || + session.minHeartRateBpm != null || session.avgRrIntervalMs != null || + session.rmssdMs != null || session.hrr60Bpm != null || + session.hrr300Bpm != null || session.maxTorsoPitchDeg != null || + session.maxTorsoRollDeg != null + ) { + queries.updateSessionPolarSummary( + id = session.id, + avgHeartRateBpm = session.avgHeartRateBpm?.toLong(), + maxHeartRateBpm = session.maxHeartRateBpm?.toLong(), + minHeartRateBpm = session.minHeartRateBpm?.toLong(), + avgRrIntervalMs = session.avgRrIntervalMs?.toDouble(), + rmssdMs = session.rmssdMs?.toDouble(), + hrr60Bpm = session.hrr60Bpm?.toLong(), + hrr300Bpm = session.hrr300Bpm?.toLong(), + maxTorsoPitchDeg = session.maxTorsoPitchDeg?.toDouble(), + maxTorsoRollDeg = session.maxTorsoRollDeg?.toDouble(), + ) + } if (session.portalOrigin || session.updatedAt != null || session.syncAcknowledged) { // Same transaction as the insert, so an aborted // restore never leaves a half-marked row. @@ -2523,6 +2603,106 @@ abstract class BaseDataBackupManager( } } + // --- polarBeatSamples --- + "polarBeatSamples" -> { + committedTransaction { + nav.beginArray() + while (nav.hasNextInArray()) { + val rawJson = nav.nextValueAsString() + val beat = tryImport("polarBeatSample-parse", null) { + json.decodeFromString(rawJson) + } ?: continue + + if (!parentAvailable("session", beat.sessionId) || + queries.selectSessionById(beat.sessionId).executeAsOneOrNull() == null + ) { + entitiesWithErrors++ + Logger.w { "Backup restore missing required session ${beat.sessionId} for polarBeatSample ${beat.id}" } + } else { + val existing = queries.selectPolarBeatSampleById(beat.id).executeAsOneOrNull() + if (existing != null) { + if (mapPolarBeatSampleToBackup(existing) == beat) { + polarBeatSamplesSkipped++ + } else { + entitiesWithErrors++ + Logger.w { "Backup restore conflict polarBeatSample id=${beat.id}; local row preserved" } + } + continue + } + queries.insertPolarBeatSampleRestoreIfAbsent( + id = beat.id, + sessionId = beat.sessionId, + timestampMs = beat.timestampMs, + phase = beat.phase, + hrBpm = beat.hrBpm.toLong(), + rrMs = beat.rrMs.toDouble(), + rmssdMs = beat.rmssdMs?.toDouble(), + torsoPitchDeg = beat.torsoPitchDeg?.toDouble(), + torsoRollDeg = beat.torsoRollDeg?.toDouble(), + dynamicAccMg = beat.dynamicAccMg?.toLong(), + ) + val inserted = queries.selectChangedRowCount().executeAsOne() > 0L + if (inserted) { + queries.markWorkoutComponentDirty(beat.sessionId) + holdGroupOfRestoredChild(beat.sessionId) + polarBeatSamplesImported++ + } else { + polarBeatSamplesSkipped++ + } + } + } + nav.endArray() + } + } + + // --- polarEcgSegments --- + "polarEcgSegments" -> { + committedTransaction { + nav.beginArray() + while (nav.hasNextInArray()) { + val rawJson = nav.nextValueAsString() + val segment = tryImport("polarEcgSegment-parse", null) { + json.decodeFromString(rawJson) + } ?: continue + + if (!parentAvailable("session", segment.sessionId) || + queries.selectSessionById(segment.sessionId).executeAsOneOrNull() == null + ) { + entitiesWithErrors++ + Logger.w { "Backup restore missing required session ${segment.sessionId} for polarEcgSegment ${segment.id}" } + } else { + val existing = queries.selectPolarEcgSegmentById(segment.id).executeAsOneOrNull() + if (existing != null) { + if (mapPolarEcgSegmentToBackup(existing) == segment) { + polarEcgSegmentsSkipped++ + } else { + entitiesWithErrors++ + Logger.w { "Backup restore conflict polarEcgSegment id=${segment.id}; local row preserved" } + } + continue + } + queries.insertPolarEcgSegmentRestoreIfAbsent( + id = segment.id, + sessionId = segment.sessionId, + sensorTimestampNs = segment.sensorTimestampNs, + wallClockTimestampMs = segment.wallClockTimestampMs, + phase = segment.phase, + samplesUvCsv = segment.samplesUvCsv, + ) + val inserted = queries.selectChangedRowCount().executeAsOne() > 0L + if (inserted) { + queries.markWorkoutComponentDirty(segment.sessionId) + holdGroupOfRestoredChild(segment.sessionId) + polarEcgSegmentsImported++ + } else { + polarEcgSegmentsSkipped++ + } + } + } + nav.endArray() + } + } + // --- progressionEvents --- "progressionEvents" -> { committedTransaction { @@ -2878,6 +3058,10 @@ abstract class BaseDataBackupManager( cycleSyncStatesSkipped = cycleSyncStatesSkipped, cycleConflictDraftsImported = cycleConflictDraftsImported, cycleConflictDraftsSkipped = cycleConflictDraftsSkipped, + polarBeatSamplesImported = polarBeatSamplesImported, + polarBeatSamplesSkipped = polarBeatSamplesSkipped, + polarEcgSegmentsImported = polarEcgSegmentsImported, + polarEcgSegmentsSkipped = polarEcgSegmentsSkipped, repairedReferences = repairedReferences, entitiesWithErrors = entitiesWithErrors, ) @@ -3001,18 +3185,20 @@ abstract class BaseDataBackupManager( writer.write("\"metricSamples\":[") var metricIndex = 0L var firstMetric = true - // Raw telemetry is opt-in (F-033); the empty section keeps the file shape stable. - for (session in if (includeRawTelemetryInBackups) sessions else emptyList()) { - val sessionMetrics = queries.selectMetricsBySession(session.id).executeAsList() - for (metric in sessionMetrics) { - if (!firstMetric) writer.write(",") - firstMetric = false - writer.write(json.encodeToString(MetricSampleBackup.serializer(), mapMetricToBackup(metric))) - metricIndex++ - } - if (sessionMetrics.isNotEmpty()) { - writer.flush() - onProgress(BackupProgress(BackupPhase.METRICS, metricIndex, metricCount)) + // Raw telemetry is opt-in (F-033). + if (includeRawTelemetryInBackups) { + for (session in sessions) { + val sessionMetrics = queries.selectMetricsBySession(session.id).executeAsList() + for (metric in sessionMetrics) { + if (!firstMetric) writer.write(",") + firstMetric = false + writer.write(json.encodeToString(MetricSampleBackup.serializer(), mapMetricToBackup(metric))) + metricIndex++ + } + if (sessionMetrics.isNotEmpty()) { + writer.flush() + onProgress(BackupProgress(BackupPhase.METRICS, metricIndex, metricCount)) + } } } writer.write("],") @@ -3075,6 +3261,42 @@ abstract class BaseDataBackupManager( }) writer.write(",") + writer.write("\"polarBeatSamples\":[") + var firstBeat = true + if (includeRawTelemetryInBackups) { + for (session in sessions) { + val sessionBeats = queries.selectPolarBeatSamplesBySession(session.id).executeAsList() + for (beat in sessionBeats) { + if (!firstBeat) writer.write(",") + firstBeat = false + writer.write(json.encodeToString(PolarBeatSampleBackup.serializer(), mapPolarBeatSampleToBackup(beat))) + } + if (sessionBeats.isNotEmpty()) { + writer.flush() + } + } + } + writer.write("],") + writer.flush() + + writer.write("\"polarEcgSegments\":[") + var firstEcg = true + if (includeRawTelemetryInBackups) { + for (session in sessions) { + val sessionEcgs = queries.selectPolarEcgSegmentsBySession(session.id).executeAsList() + for (ecg in sessionEcgs) { + if (!firstEcg) writer.write(",") + firstEcg = false + writer.write(json.encodeToString(PolarEcgSegmentBackup.serializer(), mapPolarEcgSegmentToBackup(ecg))) + } + if (sessionEcgs.isNotEmpty()) { + writer.flush() + } + } + } + writer.write("],") + writer.flush() + val progressionEvents = queries.selectAllProgressionEventsSync().executeAsList() writeJsonArray(writer, "progressionEvents", progressionEvents.map { json.encodeToString(ProgressionEventBackup.serializer(), mapProgressionEventToBackup(it)) }) writer.write(",") @@ -3366,6 +3588,15 @@ abstract class BaseDataBackupManager( // 0/0; a local row needs at least one acknowledged push. syncAcknowledged = session.synced_sync_generation >= session.local_sync_generation && (session.portalOrigin == 1L || session.synced_sync_generation > 0L), + avgHeartRateBpm = session.avgHeartRateBpm?.toInt(), + maxHeartRateBpm = session.maxHeartRateBpm?.toInt(), + minHeartRateBpm = session.minHeartRateBpm?.toInt(), + avgRrIntervalMs = session.avgRrIntervalMs?.toFloat(), + rmssdMs = session.rmssdMs?.toFloat(), + hrr60Bpm = session.hrr60Bpm?.toInt(), + hrr300Bpm = session.hrr300Bpm?.toInt(), + maxTorsoPitchDeg = session.maxTorsoPitchDeg?.toFloat(), + maxTorsoRollDeg = session.maxTorsoRollDeg?.toFloat(), ) } @@ -3381,6 +3612,36 @@ abstract class BaseDataBackupManager( loadB = metric.loadB?.toFloat(), power = metric.power?.toFloat(), status = metric.status.toInt(), + heartRateBpm = metric.heartRateBpm?.toInt(), + rrIntervalMs = metric.rrIntervalMs?.toFloat(), + torsoPitchDeg = metric.torsoPitchDeg?.toFloat(), + torsoRollDeg = metric.torsoRollDeg?.toFloat(), + dynamicAccMg = metric.dynamicAccMg?.toInt(), + chestAccXMg = metric.chestAccXMg?.toInt(), + chestAccYMg = metric.chestAccYMg?.toInt(), + chestAccZMg = metric.chestAccZMg?.toInt(), + ) + + private fun mapPolarBeatSampleToBackup(sample: PolarBeatSample): PolarBeatSampleBackup = PolarBeatSampleBackup( + id = sample.id, + sessionId = sample.sessionId, + timestampMs = sample.timestampMs, + phase = sample.phase, + hrBpm = sample.hrBpm.toInt(), + rrMs = sample.rrMs.toFloat(), + rmssdMs = sample.rmssdMs?.toFloat(), + torsoPitchDeg = sample.torsoPitchDeg?.toFloat(), + torsoRollDeg = sample.torsoRollDeg?.toFloat(), + dynamicAccMg = sample.dynamicAccMg?.toInt(), + ) + + private fun mapPolarEcgSegmentToBackup(segment: PolarEcgSegment): PolarEcgSegmentBackup = PolarEcgSegmentBackup( + id = segment.id, + sessionId = segment.sessionId, + sensorTimestampNs = segment.sensorTimestampNs, + wallClockTimestampMs = segment.wallClockTimestampMs, + phase = segment.phase, + samplesUvCsv = segment.samplesUvCsv, ) private fun mapRoutineToBackup(routine: Routine): RoutineBackup = RoutineBackup( @@ -4090,7 +4351,21 @@ abstract class BaseDataBackupManager( val session = queries.selectSessionById(sessionId).executeAsOneOrNull() ?: return@withContext Result.failure(Exception("Session not found: $sessionId")) - val metrics = if (includeRawTelemetryInBackups) queries.selectMetricsBySession(sessionId).executeAsList() else emptyList() + val metrics = if (includeRawTelemetryInBackups) { + queries.selectMetricsBySession(sessionId).executeAsList() + } else { + emptyList() + } + val beatSamples = if (includeRawTelemetryInBackups) { + queries.selectPolarBeatSamplesBySession(sessionId).executeAsList() + } else { + emptyList() + } + val ecgSegments = if (includeRawTelemetryInBackups) { + queries.selectPolarEcgSegmentsBySession(sessionId).executeAsList() + } else { + emptyList() + } val completedSets = queries.selectCompletedSetsBySession(sessionId).executeAsList() val sessions = listOf(session) @@ -4111,6 +4386,8 @@ abstract class BaseDataBackupManager( workoutSessions = sessions.map { mapSessionToBackup(it) }, metricSamples = metrics.map { mapMetricToBackup(it) }, completedSets = completedSets.map { mapCompletedSetToBackup(it) }, + polarBeatSamples = beatSamples.map(::mapPolarBeatSampleToBackup), + polarEcgSegments = ecgSegments.map(::mapPolarEcgSegmentToBackup), ), ) @@ -4165,8 +4442,14 @@ abstract class BaseDataBackupManager( // Done per-session to mirror exportSession()'s OOM-safe pattern. val allMetrics = mutableListOf() val allCompletedSets = mutableListOf() + val allBeatSamples = mutableListOf() + val allEcgSegments = mutableListOf() for (session in sessions) { - if (includeRawTelemetryInBackups) allMetrics.addAll(queries.selectMetricsBySession(session.id).executeAsList()) + if (includeRawTelemetryInBackups) { + allMetrics.addAll(queries.selectMetricsBySession(session.id).executeAsList()) + allBeatSamples.addAll(queries.selectPolarBeatSamplesBySession(session.id).executeAsList()) + allEcgSegments.addAll(queries.selectPolarEcgSegmentsBySession(session.id).executeAsList()) + } allCompletedSets.addAll(queries.selectCompletedSetsBySession(session.id).executeAsList()) } @@ -4183,6 +4466,8 @@ abstract class BaseDataBackupManager( workoutSessions = sessions.map { mapSessionToBackup(it) }, metricSamples = allMetrics.map { mapMetricToBackup(it) }, completedSets = allCompletedSets.map { mapCompletedSetToBackup(it) }, + polarBeatSamples = allBeatSamples.map(::mapPolarBeatSampleToBackup), + polarEcgSegments = allEcgSegments.map(::mapPolarEcgSegmentToBackup), ), ) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DeletedProfileBackupFilter.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DeletedProfileBackupFilter.kt index 2462dc23f..99fc4ea9e 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DeletedProfileBackupFilter.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DeletedProfileBackupFilter.kt @@ -103,6 +103,8 @@ internal class DeletedProfileBackupFilter(private val deletedProfileIds: Set, + timestampsMs: List? = null, + ): Double? { + if (rrs.size < 2) return null + if (timestampsMs != null && timestampsMs.size != rrs.size) return null + var sumSquares = 0.0 + var validDiffs = 0 + for (i in 1 until rrs.size) { + val rr1 = rrs[i - 1] + val rr2 = rrs[i] + if (rr1 !in 250.0..2500.0 || rr2 !in 250.0..2500.0) continue + if (timestampsMs != null) { + val timeGap = timestampsMs[i] - timestampsMs[i - 1] + if (timeGap !in 200..3500) continue + } + val diff = abs(rr2 - rr1) + if (diff > maxOf(rr1, rr2) * 0.25) continue + sumSquares += diff * diff + validDiffs++ + } + if (validDiffs == 0) return null + return sqrt(sumSquares / validDiffs) + } + + /** + * Compute RMSSD directly from PolarRrBeat list with contiguous timestamp validation. + */ + fun calculateRmssdFromBeats(beats: List): Double? { + if (beats.size < 2) return null + return calculateRmssdMs( + rrs = beats.map { it.rrIntervalMs }, + timestampsMs = beats.map { it.timestampMs }, + ) + } + + /** + * Computes rolling RMSSD (Root Mean Square of Successive Differences) in milliseconds + * over a sliding time window (default 60 seconds). + * Filters beats by the sliding window and delegates to canonical [calculateRmssdFromBeats]. + */ + fun computeRollingRmssd( + beats: List, + windowMs: Long = 60_000L, + currentTimeMs: Long, + ): Double? { + if (beats.size < 2) return null + val windowStart = currentTimeMs - windowMs + val windowBeats = beats.filter { it.timestampMs in windowStart..currentTimeMs } + return calculateRmssdFromBeats(windowBeats) + } + + /** + * Format milliseconds to a clean integer string representation. + */ + fun formatMs(ms: Double?): String = ms?.let { "${it.roundToInt()} ms" } ?: "—" +} + +/** + * Stateful exponential moving average (EMA) low-pass filter for 50 Hz Polar ACC stream. + */ +class PolarAccFilter( + private val alpha: Double = PolarTelemetryUtils.DEFAULT_EMA_ALPHA, + var tarePitchDeg: Double = 0.0, + var tareRollDeg: Double = 0.0, +) { + private var filteredX: Double? = null + private var filteredY: Double? = null + private var filteredZ: Double? = null + + fun reset() { + filteredX = null + filteredY = null + filteredZ = null + } + + /** + * Tare current standing posture as 0° pitch and 0° roll. + */ + fun tareCurrentPosture() { + val fx = filteredX ?: return + val fy = filteredY ?: return + val fz = filteredZ ?: return + tarePitchDeg = PolarTelemetryUtils.calculateTorsoPitchDeg(fx, fy, fz, tarePitchDeg = 0.0) + tareRollDeg = PolarTelemetryUtils.calculateTorsoRollDeg(fx, fy, fz, tareRollDeg = 0.0) + } + + fun processSample(xMg: Int, yMg: Int, zMg: Int): PolarTorsoBiomechanics { + val prevX = filteredX + val prevY = filteredY + val prevZ = filteredZ + + val curX = if (prevX == null) xMg.toDouble() else alpha * xMg + (1.0 - alpha) * prevX + val curY = if (prevY == null) yMg.toDouble() else alpha * yMg + (1.0 - alpha) * prevY + val curZ = if (prevZ == null) zMg.toDouble() else alpha * zMg + (1.0 - alpha) * prevZ + + filteredX = curX + filteredY = curY + filteredZ = curZ + + val pitch = PolarTelemetryUtils.calculateTorsoPitchDeg(curX, curY, curZ, tarePitchDeg) + val roll = PolarTelemetryUtils.calculateTorsoRollDeg(curX, curY, curZ, tareRollDeg) + val dynamicAcc = PolarTelemetryUtils.calculateDynamicAccMg(curX, curY, curZ) + + return PolarTorsoBiomechanics( + pitchDeg = pitch, + rollDeg = roll, + dynamicAccMg = dynamicAcc, + filteredXMg = curX, + filteredYMg = curY, + filteredZMg = curZ, + ) + } +} diff --git a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/PhoenixDatabase.sq b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/PhoenixDatabase.sq index 95f4d11c3..c7cfd9601 100644 --- a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/PhoenixDatabase.sq +++ b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/PhoenixDatabase.sq @@ -122,7 +122,17 @@ CREATE TABLE WorkoutSession ( -- first materialized from portal pull explicitly write 0/0. local_sync_generation INTEGER NOT NULL DEFAULT 1 CHECK(local_sync_generation >= 0), synced_sync_generation INTEGER NOT NULL DEFAULT 0 - CHECK(synced_sync_generation >= 0 AND synced_sync_generation <= local_sync_generation) + CHECK(synced_sync_generation >= 0 AND synced_sync_generation <= local_sync_generation), + -- Polar H10 cardiac & torso biomechanics summary (added in migration 59) + avgHeartRateBpm INTEGER, + maxHeartRateBpm INTEGER, + minHeartRateBpm INTEGER, + avgRrIntervalMs REAL, + rmssdMs REAL, + hrr60Bpm INTEGER, + hrr300Bpm INTEGER, + maxTorsoPitchDeg REAL, + maxTorsoRollDeg REAL ); CREATE INDEX idx_workout_session_timestamp ON WorkoutSession(timestamp); @@ -151,11 +161,50 @@ CREATE TABLE MetricSample ( loadB REAL, power REAL, status INTEGER NOT NULL DEFAULT 0, + -- Polar H10 intra-set synchronized telemetry & torso biomechanics (added in migration 59) + heartRateBpm INTEGER, + rrIntervalMs REAL, + torsoPitchDeg REAL, + torsoRollDeg REAL, + dynamicAccMg INTEGER, + chestAccXMg INTEGER, + chestAccYMg INTEGER, + chestAccZMg INTEGER, FOREIGN KEY (sessionId) REFERENCES WorkoutSession(id) ON DELETE CASCADE ); CREATE INDEX idx_metric_sample_session ON MetricSample(sessionId); +-- Continuous beat & recovery timeline (added in migration 59) +CREATE TABLE PolarBeatSample ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sessionId TEXT NOT NULL, + timestampMs INTEGER NOT NULL, + phase TEXT NOT NULL, + hrBpm INTEGER NOT NULL, + rrMs REAL NOT NULL, + rmssdMs REAL, + torsoPitchDeg REAL, + torsoRollDeg REAL, + dynamicAccMg INTEGER, + FOREIGN KEY (sessionId) REFERENCES WorkoutSession(id) ON DELETE CASCADE +); + +CREATE INDEX idx_polar_beat_sample_session_ts ON PolarBeatSample(sessionId, timestampMs); + +-- 130 Hz raw ECG trace storage (added in migration 59) +CREATE TABLE PolarEcgSegment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sessionId TEXT NOT NULL, + sensorTimestampNs INTEGER NOT NULL, + wallClockTimestampMs INTEGER NOT NULL, + phase TEXT NOT NULL, + samplesUvCsv TEXT NOT NULL, + FOREIGN KEY (sessionId) REFERENCES WorkoutSession(id) ON DELETE CASCADE +); + +CREATE INDEX idx_polar_ecg_segment_session_wall ON PolarEcgSegment(sessionId, wallClockTimestampMs); + -- Personal records -- Supports two PR types: MAX_WEIGHT (heaviest lift) and MAX_VOLUME (weight × reps) -- Phase field (Issue #111): tracks concentric vs eccentric vs combined PRs separately @@ -1162,19 +1211,109 @@ insertMetric: INSERT INTO MetricSample (sessionId, timestamp, position, positionB, velocity, velocityB, load, loadB, power, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +insertMetricWithPolar: +INSERT INTO MetricSample ( + sessionId, timestamp, position, positionB, velocity, velocityB, load, loadB, power, status, + heartRateBpm, rrIntervalMs, torsoPitchDeg, torsoRollDeg, dynamicAccMg, chestAccXMg, chestAccYMg, chestAccZMg +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + insertMetricRestoreIfAbsent: INSERT OR IGNORE INTO MetricSample( id, sessionId, timestamp, position, positionB, velocity, velocityB, - load, loadB, power, status + load, loadB, power, status, + heartRateBpm, rrIntervalMs, torsoPitchDeg, torsoRollDeg, dynamicAccMg, chestAccXMg, chestAccYMg, chestAccZMg ) VALUES ( :id, :sessionId, :timestamp, :position, :positionB, :velocity, :velocityB, - :load, :loadB, :power, :status + :load, :loadB, :power, :status, + :heartRateBpm, :rrIntervalMs, :torsoPitchDeg, :torsoRollDeg, :dynamicAccMg, :chestAccXMg, :chestAccYMg, :chestAccZMg ); deleteMetricsBySession: DELETE FROM MetricSample WHERE sessionId = ?; +-- Polar Queries (added in migration 59) +updateSessionPolarSummary: +UPDATE WorkoutSession SET + avgHeartRateBpm = :avgHeartRateBpm, + maxHeartRateBpm = :maxHeartRateBpm, + minHeartRateBpm = :minHeartRateBpm, + avgRrIntervalMs = :avgRrIntervalMs, + rmssdMs = :rmssdMs, + hrr60Bpm = :hrr60Bpm, + hrr300Bpm = :hrr300Bpm, + maxTorsoPitchDeg = :maxTorsoPitchDeg, + maxTorsoRollDeg = :maxTorsoRollDeg +WHERE id = :id; + +updateSessionPolarRecovery: +UPDATE WorkoutSession SET + hrr60Bpm = :hrr60Bpm, + hrr300Bpm = :hrr300Bpm +WHERE id = :id; + +insertPolarBeatSample: +INSERT INTO PolarBeatSample ( + sessionId, timestampMs, phase, hrBpm, rrMs, rmssdMs, + torsoPitchDeg, torsoRollDeg, dynamicAccMg +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + +selectPolarBeatSamplesBySession: +SELECT * FROM PolarBeatSample WHERE sessionId = ? ORDER BY timestampMs ASC; + +selectPolarBeatSampleById: +SELECT * FROM PolarBeatSample WHERE id = ?; + +deletePolarBeatSamplesBySession: +DELETE FROM PolarBeatSample WHERE sessionId = ?; + +countBackupPolarBeatSamples: +SELECT COUNT(*) FROM PolarBeatSample WHERE sessionId IN (SELECT id FROM WorkoutSession WHERE deletedAt IS NULL); + +insertPolarBeatSampleRestoreIfAbsent: +INSERT OR IGNORE INTO PolarBeatSample ( + id, sessionId, timestampMs, phase, hrBpm, rrMs, rmssdMs, + torsoPitchDeg, torsoRollDeg, dynamicAccMg +) VALUES ( + :id, :sessionId, :timestampMs, :phase, :hrBpm, :rrMs, :rmssdMs, + :torsoPitchDeg, :torsoRollDeg, :dynamicAccMg +); + +insertPolarEcgSegment: +INSERT INTO PolarEcgSegment ( + sessionId, sensorTimestampNs, wallClockTimestampMs, phase, samplesUvCsv +) VALUES (?, ?, ?, ?, ?); + +selectPolarEcgSegmentsBySession: +SELECT * FROM PolarEcgSegment WHERE sessionId = ? ORDER BY wallClockTimestampMs ASC; + +selectPolarEcgSegmentById: +SELECT * FROM PolarEcgSegment WHERE id = ?; + +deleteExpiredPolarEcgSegments: +DELETE FROM PolarEcgSegment WHERE wallClockTimestampMs < :cutoffMs; + +trimPolarEcgSegments: +DELETE FROM PolarEcgSegment WHERE id IN ( + SELECT id FROM PolarEcgSegment + ORDER BY wallClockTimestampMs DESC, id DESC + LIMIT -1 OFFSET :maxSegments +); + +deletePolarEcgSegmentsBySession: +DELETE FROM PolarEcgSegment WHERE sessionId = ?; + +countBackupPolarEcgSegments: +SELECT COUNT(*) FROM PolarEcgSegment WHERE sessionId IN (SELECT id FROM WorkoutSession WHERE deletedAt IS NULL); + +insertPolarEcgSegmentRestoreIfAbsent: +INSERT OR IGNORE INTO PolarEcgSegment ( + id, sessionId, sensorTimestampNs, wallClockTimestampMs, phase, samplesUvCsv +) VALUES ( + :id, :sessionId, :sensorTimestampNs, :wallClockTimestampMs, :phase, :samplesUvCsv +); + -- Average set duration for a specific exercise (Issue #225: routine time estimation) -- Issue #586: only consider rows that look like an actual set (plausible duration -- and at least one completed rep). Stale/elapsed/zero-rep rows must be excluded diff --git a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/59.sqm b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/59.sqm new file mode 100644 index 000000000..b488d9557 --- /dev/null +++ b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/59.sqm @@ -0,0 +1,52 @@ +-- Migration 59: Polar H10 Multi-Tier Telemetry Persistence + +-- 1. MetricSample intra-set synchronized biomechanics +ALTER TABLE MetricSample ADD COLUMN heartRateBpm INTEGER; +ALTER TABLE MetricSample ADD COLUMN rrIntervalMs REAL; +ALTER TABLE MetricSample ADD COLUMN torsoPitchDeg REAL; +ALTER TABLE MetricSample ADD COLUMN torsoRollDeg REAL; +ALTER TABLE MetricSample ADD COLUMN dynamicAccMg INTEGER; +ALTER TABLE MetricSample ADD COLUMN chestAccXMg INTEGER; +ALTER TABLE MetricSample ADD COLUMN chestAccYMg INTEGER; +ALTER TABLE MetricSample ADD COLUMN chestAccZMg INTEGER; + +-- 2. PolarBeatSample: continuous beat & recovery timeline +CREATE TABLE IF NOT EXISTS PolarBeatSample ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sessionId TEXT NOT NULL, + timestampMs INTEGER NOT NULL, + phase TEXT NOT NULL, + hrBpm INTEGER NOT NULL, + rrMs REAL NOT NULL, + rmssdMs REAL, + torsoPitchDeg REAL, + torsoRollDeg REAL, + dynamicAccMg INTEGER, + FOREIGN KEY (sessionId) REFERENCES WorkoutSession(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_polar_beat_sample_session_ts ON PolarBeatSample(sessionId, timestampMs); + +-- 3. PolarEcgSegment: 130 Hz raw ECG trace storage +CREATE TABLE IF NOT EXISTS PolarEcgSegment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sessionId TEXT NOT NULL, + sensorTimestampNs INTEGER NOT NULL, + wallClockTimestampMs INTEGER NOT NULL, + phase TEXT NOT NULL, + samplesUvCsv TEXT NOT NULL, + FOREIGN KEY (sessionId) REFERENCES WorkoutSession(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_polar_ecg_segment_session_wall ON PolarEcgSegment(sessionId, wallClockTimestampMs); + +-- 4. WorkoutSession summary columns +ALTER TABLE WorkoutSession ADD COLUMN avgHeartRateBpm INTEGER; +ALTER TABLE WorkoutSession ADD COLUMN maxHeartRateBpm INTEGER; +ALTER TABLE WorkoutSession ADD COLUMN minHeartRateBpm INTEGER; +ALTER TABLE WorkoutSession ADD COLUMN avgRrIntervalMs REAL; +ALTER TABLE WorkoutSession ADD COLUMN rmssdMs REAL; +ALTER TABLE WorkoutSession ADD COLUMN hrr60Bpm INTEGER; +ALTER TABLE WorkoutSession ADD COLUMN hrr300Bpm INTEGER; +ALTER TABLE WorkoutSession ADD COLUMN maxTorsoPitchDeg REAL; +ALTER TABLE WorkoutSession ADD COLUMN maxTorsoRollDeg REAL; diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/CompositionGattFake.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/CompositionGattFake.kt index 1cb3b0680..8c8666130 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/CompositionGattFake.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/CompositionGattFake.kt @@ -31,9 +31,10 @@ import kotlinx.coroutines.flow.onSubscription /** Kable API double, not a simulated radio, CoreBluetooth stack, or trainer firmware. */ internal class CompositionAdvertisement( - id: String, + id: String = "00000000-0000-0000-0000-001122334455", override val name: String? = "Vee_Composition", override val rssi: Int = -45, + override val uuids: List = listOf(BleConstants.NUS_SERVICE_UUID), ) : Advertisement { // Identifier is Uuid on Apple and String on Android. Validate UUIDs on both, // but do not call Android's MAC validator: this is not a radio advertisement. @@ -43,7 +44,7 @@ internal class CompositionAdvertisement( override val peripheralName: String? = name override val isConnectable: Boolean = true override val txPower: Int? = null - override val uuids: List = listOf(BleConstants.NUS_SERVICE_UUID) + override val manufacturerData: ManufacturerData? = null override fun serviceData(uuid: Uuid): ByteArray? = null override fun manufacturerData(companyIdentifierCode: Int): ByteArray? = null diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt new file mode 100644 index 000000000..23bdc6c39 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt @@ -0,0 +1,1266 @@ +package com.devil.phoenixproject.data.ble + +import com.devil.phoenixproject.data.repository.SqlDelightWorkoutRepository +import com.devil.phoenixproject.data.repository.SqlDelightPolarTelemetryRepository +import com.devil.phoenixproject.data.repository.ScannedDevice +import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.domain.model.PolarAccSample +import com.devil.phoenixproject.domain.model.PolarHrMeasurement +import com.devil.phoenixproject.domain.model.PolarPmdMeasurement +import com.devil.phoenixproject.domain.model.PolarRrBeat +import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.testutil.FakeExerciseRepository +import com.devil.phoenixproject.util.BleConstants +import com.juul.kable.Characteristic +import com.juul.kable.Peripheral +import com.juul.kable.WriteType +import com.russhwolf.settings.MapSettings +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.uuid.ExperimentalUuidApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent + +@OptIn(ExperimentalUuidApi::class) +class PolarH10BleManagerTest { + + // ==================== Advertisement Filter Tests ==================== + + @Test + fun `isPolarH10Advertisement matches Polar H10 device names case-insensitively`() { + val manager = PolarH10BleManager() + + assertTrue(manager.isPolarH10Advertisement(CompositionAdvertisement(name = "Polar H10 12345678", uuids = emptyList()))) + assertTrue(manager.isPolarH10Advertisement(CompositionAdvertisement(name = "polar h10 9999", uuids = emptyList()))) + assertTrue(manager.isPolarH10Advertisement(CompositionAdvertisement(name = "POLAR H10", uuids = emptyList()))) + + assertFalse(manager.isPolarH10Advertisement(CompositionAdvertisement(name = "Vee_Trainer", uuids = emptyList()))) + assertFalse(manager.isPolarH10Advertisement(CompositionAdvertisement(name = "VIT-200", uuids = emptyList()))) + assertFalse(manager.isPolarH10Advertisement(CompositionAdvertisement(name = null, uuids = emptyList()))) + } + + @Test + fun `standard Heart Rate UUID alone never identifies an H10`() { + val manager = PolarH10BleManager() + + val hrUuid = BleConstants.Polar.HR_SERVICE_UUID + assertFalse(manager.isPolarH10Advertisement(CompositionAdvertisement(name = null, uuids = listOf(hrUuid)))) + assertFalse(manager.isPolarH10Advertisement(CompositionAdvertisement(name = "Polar H9", uuids = listOf(hrUuid)))) + assertFalse(manager.isPolarH10Advertisement(CompositionAdvertisement(name = "Polar H100", uuids = listOf(hrUuid)))) + + val nusUuid = BleConstants.NUS_SERVICE_UUID + assertFalse(manager.isPolarH10Advertisement(CompositionAdvertisement(name = "Unknown", uuids = listOf(nusUuid)))) + } + + // ==================== Initial State Tests ==================== + + @Test + fun `initial states are correctly disconnected and empty`() { + val manager = PolarH10BleManager() + + assertEquals(ConnectionState.Disconnected, manager.connectionState.value) + assertTrue(manager.scannedDevices.value.isEmpty()) + assertNull(manager.batteryLevel.value) + assertNull(manager.hrMeasurement.value) + assertNull(manager.latestEcg.value) + assertNull(manager.latestAcc.value) + } + + // ==================== Chained RR-Interval Clock Tests ==================== + + @Test + fun `chained RR clock accurately calculates timestamps across consecutive packets`() { + var simulatedTime = 100_000L + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(scope = testScope, timeSource = { simulatedTime }) + + val collectedBeats = mutableListOf() + val collectJob = testScope.launch { + manager.rrBeats.collect { collectedBeats.add(it) } + } + + // Packet 1 at t = 100,000 ms with two RR intervals: 800.0 ms and 850.0 ms + // total duration = 1650 ms + // anchor = 100,000 - 1650 = 98,350 ms + // Beat 1: 98,350 + 800 = 99,150 ms + // Beat 2: 99,150 + 850 = 100,000 ms + manager.processRrIntervals( + PolarHrMeasurement( + hr = 72, + contactDetected = true, + rrIntervalsMs = listOf(800.0, 850.0), + ), + ) + + assertEquals(2, collectedBeats.size) + assertEquals(800.0, collectedBeats[0].rrIntervalMs) + assertEquals(99_150L, collectedBeats[0].timestampMs) + assertEquals(72, collectedBeats[0].hr) + + assertEquals(850.0, collectedBeats[1].rrIntervalMs) + assertEquals(100_000L, collectedBeats[1].timestampMs) + assertEquals(72, collectedBeats[1].hr) + + // Packet 2 arrives at t = 100,920 ms (packet transport delayed by 20ms jitter) + // Previous beat was at 100,000 ms. + // Beat 3 RR = 900.0 ms -> timestamp = 100,000 + 900 = 100,900 ms (jitter eliminated!) + simulatedTime = 100_920L + manager.processRrIntervals( + PolarHrMeasurement( + hr = 70, + contactDetected = true, + rrIntervalsMs = listOf(900.0), + ), + ) + + assertEquals(3, collectedBeats.size) + assertEquals(900.0, collectedBeats[2].rrIntervalMs) + assertEquals(100_900L, collectedBeats[2].timestampMs) + assertEquals(70, collectedBeats[2].hr) + + collectJob.cancel() + testScope.cancel() + } + + @Test + fun `chained RR clock re-anchors when packet gap exceeds 5 seconds`() { + var simulatedTime = 50_000L + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(scope = testScope, timeSource = { simulatedTime }) + + val collectedBeats = mutableListOf() + val collectJob = testScope.launch { + manager.rrBeats.collect { collectedBeats.add(it) } + } + + // Initial beat at t = 50,000 ms + manager.processRrIntervals( + PolarHrMeasurement( + hr = 60, + contactDetected = true, + rrIntervalsMs = listOf(1000.0), + ), + ) + assertEquals(1, collectedBeats.size) + assertEquals(50_000L, collectedBeats[0].timestampMs) + + // Long silence / disconnection gap of 15 seconds: packet arrives at t = 65,000 ms + simulatedTime = 65_000L + manager.processRrIntervals( + PolarHrMeasurement( + hr = 75, + contactDetected = true, + rrIntervalsMs = listOf(800.0), + ), + ) + + // Since gap (65,000 - 50,000 = 15,000 ms) > 5,000 ms, clock re-anchors to 65,000 ms + assertEquals(2, collectedBeats.size) + assertEquals(65_000L, collectedBeats[1].timestampMs) + + collectJob.cancel() + testScope.cancel() + } + + @Test + fun `processRrIntervals ignores empty RR lists without modifying clock`() = kotlinx.coroutines.test.runTest { + val manager = PolarH10BleManager() + val beats = mutableListOf() + val job = launch { manager.rrBeats.collect { beats.add(it) } } + manager.processRrIntervals(PolarHrMeasurement(hr = 80, contactDetected = true, rrIntervalsMs = emptyList())) + assertTrue(beats.isEmpty()) + job.cancel() + } + + // ==================== Session Recording & Cooldown Tests ==================== + + @Test + fun `session recording and cooldown lifecycle updates state flows properly`() { + var simulatedTime = 100_000L + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(scope = testScope, timeSource = { simulatedTime }) + + assertFalse(manager.isCooldownActive.value) + assertEquals(0, manager.cooldownSecondsRemaining.value) + + // Start session recording + manager.startSessionRecording("session-123", "WARMUP") + assertEquals("session-123", manager.currentRecordingSessionId) + + // Feed some beats + manager.processRrIntervals( + PolarHrMeasurement( + hr = 150, + contactDetected = true, + rrIntervalsMs = listOf(800.0), + ) + ) + + val activeSummary = manager.getSessionSummary("session-123") + assertEquals(150, activeSummary?.avgHeartRateBpm) + + // Trigger cooldown + manager.triggerCooldown("session-123") + assertTrue(manager.isCooldownActive.value) + assertEquals(300, manager.cooldownSecondsRemaining.value) + assertEquals(150, manager.cooldownStartHeartRate.value) + assertEquals("session-123", manager.activeCooldownSessionId.value) + + // Stop cooldown early + manager.stopCooldown() + assertFalse(manager.isCooldownActive.value) + assertEquals(0, manager.cooldownSecondsRemaining.value) + + testScope.cancel() + } + + @Test + fun `concurrent getSessionSummary reading while HR callbacks append is thread-safe`() { + val testScope = CoroutineScope(Dispatchers.Default + Job()) + val manager = PolarH10BleManager(scope = testScope, timeSource = { 100_000L }) + manager.startSessionRecording("session-stress", "WORKING_SET") + + var exceptionThrown: Throwable? = null + val writeJob = testScope.launch { + for (i in 1..500) { + manager.processRrIntervals( + PolarHrMeasurement( + hr = 120 + (i % 30), + contactDetected = true, + rrIntervalsMs = listOf(800.0 + (i % 50)), + ) + ) + } + } + + val readJob = testScope.launch { + try { + for (i in 1..500) { + val summary = manager.getSessionSummary("session-stress") + if (summary?.avgHeartRateBpm != null) { + assertTrue(summary.avgHeartRateBpm >= 120) + } + } + } catch (e: Throwable) { + exceptionThrown = e + } + } + + kotlinx.coroutines.runBlocking { + writeJob.join() + readJob.join() + } + + assertNull(exceptionThrown, "Concurrent reading and writing should not throw ConcurrentModificationException: ${exceptionThrown?.message}") + val finalSummary = manager.getSessionSummary("session-stress") + assertTrue(finalSummary != null && finalSummary.avgHeartRateBpm != null) + testScope.cancel() + } + + @Test + fun `routine intermediate sets update phase to REST while final completion triggers cooldown`() { + var simulatedTime = 100_000L + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(scope = testScope, timeSource = { simulatedTime }) + + // 1. Start routine set 1 + manager.startSessionRecording("routine-session-1", "WARMUP") + manager.processRrIntervals(PolarHrMeasurement(hr = 145, contactDetected = true, rrIntervalsMs = listOf(800.0))) + manager.updateSessionPhase("WORKING_SET") + + // 2. Set 1 finishes -> transitions to REST, NOT cooldown + manager.updateSessionPhase("REST") + assertFalse(manager.isCooldownActive.value, "Intermediate set summary must NOT activate cooldown") + assertEquals(0, manager.cooldownSecondsRemaining.value) + + // 3. Set 2 starts -> transitions to WORKING_SET + manager.updateSessionPhase("WORKING_SET") + assertFalse(manager.isCooldownActive.value) + + // 4. Routine complete -> triggers cooldown + manager.endSessionRecording("routine-session-1") + assertTrue(manager.isCooldownActive.value, "Final routine completion must activate cooldown") + assertEquals(300, manager.cooldownSecondsRemaining.value) + + // 5. User adds an exercise on the fly (resuming routine) -> WORKING_SET cleanly cancels cooldown + manager.updateSessionPhase("WORKING_SET") + assertFalse(manager.isCooldownActive.value, "Resuming routine with added exercise must cancel cooldown") + assertEquals(0, manager.cooldownSecondsRemaining.value) + + // 6. The resumed set records under its own session before routine completion. + manager.startSessionRecording("routine-session-2", "WORKING_SET") + manager.processRrIntervals(PolarHrMeasurement(hr = 150, contactDetected = true, rrIntervalsMs = listOf(800.0))) + assertFalse(manager.endSessionRecording("routine-session-1")) + manager.endSessionRecording("routine-session-2") + assertTrue(manager.isCooldownActive.value, "Re-completing routine must reactivate cooldown") + assertEquals(300, manager.cooldownSecondsRemaining.value) + + testScope.cancel() + } + + @Test + fun `completed cooldown guards against wiping start HR unless forceRestart is true`() { + var simulatedTime = 100_000L + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(scope = testScope, timeSource = { simulatedTime }) + + manager.startSessionRecording("session-guard", "WORKING_SET") + manager.processRrIntervals(PolarHrMeasurement(hr = 160, contactDetected = true, rrIntervalsMs = listOf(700.0))) + + // Trigger cooldown and stop/complete it + manager.triggerCooldown("session-guard") + assertEquals(160, manager.cooldownStartHeartRate.value) + manager.stopCooldown() + assertFalse(manager.isCooldownActive.value) + + // Re-entering screen triggers cooldown for same session with current HR lower + manager.processRrIntervals(PolarHrMeasurement(hr = 72, contactDetected = true, rrIntervalsMs = listOf(850.0))) + manager.triggerCooldown("session-guard") // without forceRestart + assertFalse(manager.isCooldownActive.value, "Should not restart completed cooldown without forceRestart") + assertEquals(160, manager.cooldownStartHeartRate.value, "Should not wipe cooldown start HR") + + // Explicit tap on Restart Cooldown + manager.triggerCooldown("session-guard", forceRestart = true) + assertTrue(manager.isCooldownActive.value, "Explicit restart must reactivate cooldown") + assertEquals(72, manager.cooldownStartHeartRate.value, "Restart re-anchors to current HR") + + testScope.cancel() + } + + @Test + fun `cooldown anchors peak HR during first 30 seconds`() = runTest { + var simulatedTime = 100_000L + val manager = PolarH10BleManager(scope = this, timeSource = { simulatedTime }) + + manager.startSessionRecording("session-peak", "WORKING_SET") + manager.processRrIntervals(PolarHrMeasurement(hr = 140, contactDetected = true, rrIntervalsMs = listOf(800.0))) + + // End session recording (start HR = 140) + manager.endSessionRecording("session-peak") + assertEquals(140, manager.cooldownStartHeartRate.value) + + // 10 seconds in, HR peaks at 155 + simulatedTime += 10_000L + manager.processRrIntervals(PolarHrMeasurement(hr = 155, contactDetected = true, rrIntervalsMs = listOf(750.0))) + testScheduler.advanceTimeBy(1000L) + assertEquals(155, manager.cooldownStartHeartRate.value, "Peak HR should be anchored within first 30 seconds") + + // At 65 seconds, HR drops to 120 -> HRR60 measured from peak (155 - 120 = 35) + simulatedTime += 55_000L + manager.processRrIntervals(PolarHrMeasurement(hr = 120, contactDetected = true, rrIntervalsMs = listOf(850.0))) + testScheduler.advanceTimeBy(1000L) + assertEquals(35, manager.cooldownHrr60.value, "HRR60 drop should be measured from peak HR (155 - 120 = 35)") + + manager.stopCooldown() + } + + // ==================== Signal Validity & Connection Gate Tests ==================== + + @Test + fun `isConnected state flow tracks connection and disconnection properly`() { + val manager = PolarH10BleManager() + assertFalse(manager.isConnected.value, "Initial state must be not connected") + + manager.setConnectedForTesting(true) + assertTrue(manager.isConnected.value, "State must be connected after successful connect") + assertTrue(manager.connectionState.value is ConnectionState.Connected) + + manager.setConnectedForTesting(false) + assertFalse(manager.isConnected.value, "State must be disconnected after disconnect") + assertEquals(ConnectionState.Disconnected, manager.connectionState.value) + } + + @Test + fun `strap removal or zero HR immediately clears cached telemetry and drops samples`() { + var simulatedTime = 100_000L + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(scope = testScope, timeSource = { simulatedTime }) + + val collectedBeats = mutableListOf() + val collectJob = testScope.launch { + manager.rrBeats.collect { collectedBeats.add(it) } + } + + // 1. Valid telemetry packet arrives + manager.processRrIntervals( + PolarHrMeasurement( + hr = 135, + contactDetected = true, + rrIntervalsMs = listOf(800.0), + ) + ) + assertEquals(1, collectedBeats.size) + assertEquals(135, manager.hrMeasurement.value?.hr) + + // 2. User removes strap (contactDetected = false, hr = 0) + simulatedTime += 1000L + manager.processRrIntervals( + PolarHrMeasurement( + hr = 0, + contactDetected = false, + rrIntervalsMs = listOf(800.0), + ) + ) + + // No new beat emitted, cached states cleared to null + assertEquals(1, collectedBeats.size, "Should not emit beats when strap is removed") + assertNull(manager.hrMeasurement.value, "hrMeasurement should be cleared upon strap removal") + + collectJob.cancel() + testScope.cancel() + } + + @Test + fun `strap removal is debounced while disconnect immediately terminates recovery`() = runTest { + var simulatedTime = 100_000L + val testScope = backgroundScope + val manager = PolarH10BleManager(scope = testScope, timeSource = { simulatedTime }) + + manager.startSessionRecording("session-recovery", "WORKING_SET") + manager.processRrIntervals(PolarHrMeasurement(hr = 150, contactDetected = true, rrIntervalsMs = listOf(800.0))) + manager.triggerCooldown("session-recovery") + + assertTrue(manager.isCooldownActive.value, "Cooldown must be active") + assertEquals(300, manager.cooldownSecondsRemaining.value) + + // User unhooks strap during recovery cooldown + manager.processRrIntervals(PolarHrMeasurement(hr = 0, contactDetected = false, rrIntervalsMs = emptyList())) + + assertTrue(manager.isCooldownActive.value) + runCurrent() + advanceTimeBy(3001) + assertFalse(manager.isCooldownActive.value, "Sustained contact loss must terminate cooldown") + assertEquals(0, manager.cooldownSecondsRemaining.value) + + // Reactivate cooldown and verify disconnection terminates it + manager.startSessionRecording("session-recovery", "WORKING_SET") + manager.triggerCooldown("session-recovery", forceRestart = true) + assertTrue(manager.isCooldownActive.value) + + manager.setConnectedForTesting(false) + assertFalse(manager.isCooldownActive.value, "Cooldown must be terminated upon disconnection") + assertEquals(0, manager.cooldownSecondsRemaining.value) + + testScope.cancel() + } + + @Test + fun `packet absence timeout exceeding 3 seconds clears telemetry and terminates cooldown`() { + var simulatedTime = 100_000L + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(scope = testScope, timeSource = { simulatedTime }) + + manager.startSessionRecording("session-timeout", "WORKING_SET") + manager.processRrIntervals(PolarHrMeasurement(hr = 140, contactDetected = true, rrIntervalsMs = listOf(800.0))) + manager.triggerCooldown("session-timeout") + + assertTrue(manager.isCooldownActive.value) + assertEquals(140, manager.hrMeasurement.value?.hr) + + // Advance time by 4 seconds (gap > 3 seconds) + simulatedTime += 4000L + manager.checkPacketAbsenceTimeout(simulatedTime) + + assertNull(manager.hrMeasurement.value, "Telemetry state must be cleared after >3s absence") + assertFalse(manager.isCooldownActive.value, "Cooldown must be terminated after >3s packet absence") + assertEquals(0, manager.cooldownSecondsRemaining.value) + + testScope.cancel() + } + + // ==================== Zero-Contact Telemetry & Noise Suppression Tests ==================== + + @Test + fun `lost electrode contact drops all incoming PMD ECG and ACC packets and emits zero telemetry`() { + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(scope = testScope) + + // 1. Establish initial good skin contact + manager.processRrIntervals(PolarHrMeasurement(hr = 120, contactDetected = true, rrIntervalsMs = listOf(800.0))) + assertTrue(manager.isContactDetected.value, "Contact should be true with good strap contact") + + // 2. Deliver valid ECG & ACC + manager.processPmdMeasurement( + PolarPmdMeasurement.Ecg(sensorTimestampNs = 1_000_000L, samplesMicrovolts = listOf(100, 200, 300)) + ) + assertEquals(listOf(100, 200, 300), manager.latestEcg.value?.samplesMicrovolts) + + manager.processPmdMeasurement( + PolarPmdMeasurement.Acc(sensorTimestampNs = 1_000_000L, samples = listOf(PolarAccSample(10, 20, 1000))) + ) + assertNotNull(manager.latestAcc.value) + assertNotNull(manager.torsoPitchDeg.value) + + // 3. User unclips pod / loses skin contact (contactDetected = false, hr = 0) + manager.processRrIntervals(PolarHrMeasurement(hr = 0, contactDetected = false, rrIntervalsMs = emptyList())) + assertFalse(manager.isContactDetected.value, "Contact state must drop to false") + assertNull(manager.hrMeasurement.value, "HR must be cleared to null on contact loss") + assertNull(manager.latestEcg.value, "ECG must be cleared to null on contact loss") + assertNull(manager.latestAcc.value, "ACC must be cleared to null on contact loss") + assertNull(manager.torsoPitchDeg.value, "Posture must be cleared to null on contact loss") + assertNull(manager.torsoRollDeg.value) + assertNull(manager.dynamicAccMg.value) + + // 4. PMD packets continue streaming while strap is unclipped - verify they are completely dropped + manager.processPmdMeasurement( + PolarPmdMeasurement.Ecg(sensorTimestampNs = 2_000_000L, samplesMicrovolts = listOf(150, 250, 350)) + ) + manager.processPmdMeasurement( + PolarPmdMeasurement.Acc(sensorTimestampNs = 2_000_000L, samples = listOf(PolarAccSample(30, 40, 990))) + ) + + assertNull(manager.latestEcg.value, "ECG packets must be discarded while contact is lost") + assertNull(manager.latestAcc.value, "ACC packets must be discarded while contact is lost") + assertNull(manager.torsoPitchDeg.value, "Posture must remain null while contact is lost") + assertNull(manager.dynamicAccMg.value) + + testScope.cancel() + } + + @Test + fun `ECG railing beyond physiological threshold drops transient frame without revoking contact`() { + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(scope = testScope) + + // Initial valid contact + manager.processRrIntervals(PolarHrMeasurement(hr = 70, contactDetected = true, rrIntervalsMs = listOf(850.0))) + assertTrue(manager.isContactDetected.value) + assertEquals(70, manager.hrMeasurement.value?.hr) + + // Deliver railed open-circuit noise (>= 15,000 uV) + manager.processPmdMeasurement( + PolarPmdMeasurement.Ecg( + sensorTimestampNs = 3_000_000L, + samplesMicrovolts = listOf(100, 16_000, 200), + ) + ) + + // Frame is dropped, but skin contact is NOT revoked + assertNull(manager.latestEcg.value, "Railed ECG frame must be discarded") + assertTrue(manager.isContactDetected.value, "Contact state must be preserved during transient ECG railing") + assertEquals(70, manager.hrMeasurement.value?.hr, "HR telemetry must not be cleared by transient ECG railing") + + // Next valid frame arrives and is processed normally + manager.processPmdMeasurement( + PolarPmdMeasurement.Ecg( + sensorTimestampNs = 3_500_000L, + samplesMicrovolts = listOf(150, 250, 350), + ) + ) + assertEquals(listOf(150, 250, 350), manager.latestEcg.value?.samplesMicrovolts) + + testScope.cancel() + } + + @Test + fun `ECG flatline drops transient frame without revoking contact`() { + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(scope = testScope) + + // Initial valid contact + manager.processRrIntervals(PolarHrMeasurement(hr = 75, contactDetected = true, rrIntervalsMs = listOf(800.0))) + assertTrue(manager.isContactDetected.value) + + // Deliver zero flatline + manager.processPmdMeasurement( + PolarPmdMeasurement.Ecg( + sensorTimestampNs = 4_000_000L, + samplesMicrovolts = listOf(0, 0, 0, 0), + ) + ) + + assertNull(manager.latestEcg.value, "Flatline ECG packet must be dropped") + assertTrue(manager.isContactDetected.value, "Contact state must be preserved during transient flatline") + + // Next valid frame arrives and is processed normally + manager.processPmdMeasurement( + PolarPmdMeasurement.Ecg( + sensorTimestampNs = 4_500_000L, + samplesMicrovolts = listOf(200, 300, 400), + ) + ) + assertEquals(listOf(200, 300, 400), manager.latestEcg.value?.samplesMicrovolts) + + testScope.cancel() + } + + @Test + fun `post-workout HR recovery cooldown triggers immediately and resolves session from lastRecordedSessionId`() { + var simulatedTime = 100_000L + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(scope = testScope, timeSource = { simulatedTime }) + + // 1. Session completes recording for session-post-workout + manager.startSessionRecording("session-post-workout", "WORKING_SET") + assertEquals("session-post-workout", manager.lastRecordedSessionId.value) + + manager.processRrIntervals(PolarHrMeasurement(hr = 160, contactDetected = true, rrIntervalsMs = listOf(800.0))) + assertEquals(160, manager.hrMeasurement.value?.hr) + + // Telemetry is flushed when set committed + manager.flushSessionTelemetry("session-post-workout") + assertEquals("session-post-workout", manager.lastRecordedSessionId.value) + + // 2. User clicks "Start Cooldown" on summary card without explicitly providing ID + manager.triggerCooldown() + + assertTrue(manager.isCooldownActive.value, "Cooldown must become active immediately on click") + assertEquals(300, manager.cooldownSecondsRemaining.value) + assertEquals("session-post-workout", manager.activeCooldownSessionId.value) + assertEquals(160, manager.cooldownStartHeartRate.value) + + // Cooldown summary is available + val summary = manager.getSessionSummary("session-post-workout") + assertNotNull(summary) + assertEquals("session-post-workout", summary.sessionId) + assertEquals(160, summary.avgHeartRateBpm) + + testScope.cancel() + } + + @Test + fun `critical battery level triggers contact loss and suppresses all telemetry`() { + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(scope = testScope) + + manager.processRrIntervals(PolarHrMeasurement(hr = 80, contactDetected = true, rrIntervalsMs = listOf(750.0))) + assertTrue(manager.isContactDetected.value) + + // Battery drops to critical level (4% <= 5%) + manager.setBatteryLevelForTesting(4) + assertTrue(manager.isBatteryCritical(), "Battery must be flagged critical at 4%") + assertFalse(manager.isContactDetected.value, "Critical battery must revoke contact validation") + assertNull(manager.hrMeasurement.value, "Telemetry must be cleared upon critical battery") + + // Attempting to deliver subsequent PMD and HR packets + manager.processPmdMeasurement( + PolarPmdMeasurement.Ecg(sensorTimestampNs = 5_000_000L, samplesMicrovolts = listOf(120, 220)) + ) + manager.processRrIntervals(PolarHrMeasurement(hr = 80, contactDetected = true, rrIntervalsMs = listOf(750.0))) + + assertNull(manager.latestEcg.value, "ECG must not emit with critical battery") + assertNull(manager.hrMeasurement.value, "HR must not emit with critical battery") + assertFalse(manager.isContactDetected.value) + + testScope.cancel() + } + + @Test + fun `recordedSessionId isolation rejects mismatches and cooldown end clears state`() { + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(scope = testScope, timeSource = { 100_000L }) + + // Without starting recording, all actions should reject + manager.triggerCooldown("session-unrecorded") + assertFalse(manager.isCooldownActive.value) + manager.endSessionRecording("session-unrecorded") + assertFalse(manager.isCooldownActive.value) + assertNull(manager.getSessionSummary("session-unrecorded")) + + // Start recording session-A + manager.startSessionRecording("session-A", "WORKING_SET") + manager.processRrIntervals(PolarHrMeasurement(hr = 140, contactDetected = true, rrIntervalsMs = listOf(800.0))) + + // session-B should be rejected + assertNull(manager.getSessionSummary("session-B")) + manager.triggerCooldown("session-B") + assertFalse(manager.isCooldownActive.value) + manager.endSessionRecording("session-B") + assertFalse(manager.isCooldownActive.value) + + // session-A should succeed + val summaryA = manager.getSessionSummary("session-A") + assertEquals(140, summaryA?.avgHeartRateBpm) + + manager.triggerCooldown("session-A") + assertTrue(manager.isCooldownActive.value) + assertEquals("session-A", manager.activeCooldownSessionId.value) + + // Ending cooldown clears active recording state, while preserving lastRecordedSessionId + // so the post-workout summary "Start Cooldown" button can restart cooldown for session-A + manager.stopCooldown() + assertFalse(manager.isCooldownActive.value) + assertNull(manager.activeCooldownSessionId.value) + assertNull(manager.currentRecordingSessionId) + assertEquals("session-A", manager.lastRecordedSessionId.value) + + // Once cooldown ended, summary queries reject, arbitrary sessions reject cooldown, + // but the just-finished session-A can explicitly restart cooldown via forceRestart + assertEquals(140, manager.getSessionSummary("session-A")?.avgHeartRateBpm) + manager.triggerCooldown("session-B", forceRestart = true) + assertFalse(manager.isCooldownActive.value) + manager.triggerCooldown("session-A", forceRestart = true) + assertTrue(manager.isCooldownActive.value) + assertEquals("session-A", manager.activeCooldownSessionId.value) + manager.stopCooldown() + + testScope.cancel() + } + + @Test + fun `standard HR packets with ECG OFF report isContactDetected true and emit HR and RR telemetry`() { + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(scope = testScope, timeSource = { 100_000L }) + + assertFalse(manager.isContactDetected.value) + assertFalse(manager.isEcgStreaming.value) + + val collectedBeats = mutableListOf() + val job = testScope.launch { + manager.rrBeats.collect { collectedBeats.add(it) } + } + + val measurement = PolarHrMeasurement(hr = 135, contactDetected = true, rrIntervalsMs = listOf(820.0, 830.0)) + manager.processRrIntervals(measurement) + + assertTrue(manager.isContactDetected.value) + assertEquals(135, manager.hrMeasurement.value?.hr) + assertEquals(2, collectedBeats.size) + assertEquals(820.0, collectedBeats[0].rrIntervalMs) + assertEquals(830.0, collectedBeats[1].rrIntervalMs) + + job.cancel() + testScope.cancel() + } + + @Test + fun `turning ECG ON and then OFF does not break isContactDetected or stop HR or ACC telemetry`() { + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(scope = testScope, timeSource = { 100_000L }) + + manager.processRrIntervals(PolarHrMeasurement(hr = 120, contactDetected = true, rrIntervalsMs = listOf(800.0))) + assertTrue(manager.isContactDetected.value) + + manager.setEcgStreamingForTesting(true) + assertTrue(manager.isEcgStreaming.value) + assertTrue(manager.isContactDetected.value) + + manager.setEcgStreamingForTesting(false) + assertFalse(manager.isEcgStreaming.value) + assertTrue(manager.isContactDetected.value) + + manager.processRrIntervals(PolarHrMeasurement(hr = 125, contactDetected = true, rrIntervalsMs = listOf(790.0))) + assertTrue(manager.isContactDetected.value) + assertEquals(125, manager.hrMeasurement.value?.hr) + + val accMeasurement = PolarPmdMeasurement.Acc( + sensorTimestampNs = 123456789L, + samples = listOf(PolarAccSample(xMg = 100, yMg = 200, zMg = 980)), + ) + manager.processPmdMeasurement(accMeasurement) + assertEquals(accMeasurement, manager.latestAcc.value) + assertNotNull(manager.torsoPitchDeg.value) + + testScope.cancel() + } + + @Test + fun `starting ACC alone with ECG OFF emits ACC samples without requiring ECG`() { + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(scope = testScope, timeSource = { 100_000L }) + + assertFalse(manager.isEcgStreaming.value) + + manager.processRrIntervals(PolarHrMeasurement(hr = 130, contactDetected = true, rrIntervalsMs = listOf(800.0))) + assertTrue(manager.isContactDetected.value) + + manager.setAccStreamingForTesting(true) + assertTrue(manager.isAccStreaming.value) + assertFalse(manager.isEcgStreaming.value) + + val collectedAcc = mutableListOf() + val job = testScope.launch { + manager.accStream.collect { collectedAcc.add(it) } + } + + val accMeasurement = PolarPmdMeasurement.Acc( + sensorTimestampNs = 987654321L, + samples = listOf( + PolarAccSample(xMg = 150, yMg = 250, zMg = 950), + PolarAccSample(xMg = 155, yMg = 255, zMg = 945), + ), + ) + manager.processPmdMeasurement(accMeasurement) + + assertEquals(1, collectedAcc.size) + assertEquals(accMeasurement, manager.latestAcc.value) + assertNotNull(manager.torsoPitchDeg.value) + assertNotNull(manager.torsoRollDeg.value) + assertNotNull(manager.dynamicAccMg.value) + + job.cancel() + testScope.cancel() + } + + // ==================== Connection Lifecycle & Auto-Reconnect Tests ==================== + + @Test + fun `initial State Disconnected does not trigger premature disconnect cleanup or log disconnect`() { + val manager = PolarH10BleManager() + + assertEquals(ConnectionState.Disconnected, manager.connectionState.value) + assertFalse(manager.wasEverConnectedForTesting) + assertFalse(manager.isExplicitDisconnectForTesting) + } + + @Test + fun `disconnect sets isExplicitDisconnect and cleans up connection state`() = runTest { + val manager = PolarH10BleManager() + manager.setConnectedForTesting(true) + + assertTrue(manager.isConnected.value) + assertTrue(manager.wasEverConnectedForTesting) + assertFalse(manager.isExplicitDisconnectForTesting) + + manager.disconnect() + + assertFalse(manager.isConnected.value) + assertEquals(ConnectionState.Disconnected, manager.connectionState.value) + assertTrue(manager.isExplicitDisconnectForTesting) + assertFalse(manager.wasEverConnectedForTesting) + } + + @Test + fun `startSessionRecording preserves explicit disconnect suppression`() { + val fakeAd = CompositionAdvertisement( + id = "00000000-0000-0000-0000-24acac0e5b59", + name = "Polar H10 12345678", + uuids = listOf(BleConstants.Polar.HR_SERVICE_UUID), + ) + val rememberedId = fakeAd.identifier.toString() + val settings = MapSettings() + settings.putString(PolarH10BleManager.KEY_POLAR_REMEMBERED_ADDRESS, rememberedId) + settings.putString(PolarH10BleManager.KEY_POLAR_REMEMBERED_NAME, "Polar H10 12345678") + + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager( + settings = settings, + scope = testScope, + advertisements = { flowOf(fakeAd) }, + createPeripheral = { ad -> CompositionGattFake(ad, Dispatchers.Unconfined) }, + ) + + runTest { + manager.disconnect() + } + assertTrue(manager.isExplicitDisconnectForTesting) + + // Starting a workout should clear the explicit disconnect suppression and complete auto-reconnect + // through connect() without self-cancelling reconnectJob + manager.startSessionRecording("session-123", "WORKING_SET") + + assertTrue(manager.isExplicitDisconnectForTesting) + assertFalse(manager.isConnected.value) + assertEquals( + ConnectionState.Disconnected, + manager.connectionState.value, + ) + + testScope.cancel() + } + + @Test + fun `forgetRememberedDevice clears settings and cancels pending reconnectJob`() { + val settings = MapSettings() + settings.putString(PolarH10BleManager.KEY_POLAR_REMEMBERED_ADDRESS, "24:AC:AC:0E:5B:59") + + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager( + settings = settings, + scope = testScope, + advertisements = { flow { awaitCancellation() } }, + ) + + manager.ensureConnectedOrReconnect() + assertTrue(manager.isReconnectJobActiveForTesting) + + manager.forgetRememberedDevice() + + assertNull(manager.rememberedAddress.value) + assertNull(settings.getStringOrNull(PolarH10BleManager.KEY_POLAR_REMEMBERED_ADDRESS)) + assertFalse(manager.isReconnectJobActiveForTesting) + + testScope.cancel() + } + + @Test + fun `autoReconnectIfRemembered is suppressed when auto-reconnect is disabled`() = runTest { + val settings = MapSettings() + settings.putString(PolarH10BleManager.KEY_POLAR_REMEMBERED_ADDRESS, "24:AC:AC:0E:5B:59") + settings.putBoolean(PolarH10BleManager.KEY_POLAR_AUTO_RECONNECT, false) + + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(settings = settings, scope = testScope) + + val result = manager.autoReconnectIfRemembered() + + assertTrue(result.isSuccess) + assertFalse(manager.isReconnectJobActiveForTesting) + + testScope.cancel() + } + + @Test + fun `autoReconnectIfRemembered is suppressed after explicit manual disconnect`() = runTest { + val settings = MapSettings() + settings.putString(PolarH10BleManager.KEY_POLAR_REMEMBERED_ADDRESS, "24:AC:AC:0E:5B:59") + + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager(settings = settings, scope = testScope) + + manager.disconnect() + assertTrue(manager.isExplicitDisconnectForTesting) + + val result = manager.autoReconnectIfRemembered() + + assertTrue(result.isSuccess) + assertFalse(manager.isReconnectJobActiveForTesting) + + testScope.cancel() + } + + @Test + fun `multi-set routine preserves lastRecordedSessionId across Idle discardActiveRecording and persists cooldown HRR to final set`() = runTest { + var simulatedTime = 100_000L + val database = com.devil.phoenixproject.testutil.createTestDatabase() + val manager = PolarH10BleManager( + telemetryRepository = SqlDelightPolarTelemetryRepository(database, { simulatedTime }, Dispatchers.Unconfined), + scope = this, + timeSource = { simulatedTime }, + persistenceDispatcher = Dispatchers.Unconfined, + ) + manager.setConnectedForTesting(true) + + val set1Id = "ea838be2-3983-4ec3-8eb4-c8a75fdd52a2" + val set2Id = "9615342a-142c-4cd7-838e-5cf38ab9b7f7" + val workoutRepo = SqlDelightWorkoutRepository(database, FakeExerciseRepository()) + + // 1. Set 1 recording & flush + manager.startSessionRecording(set1Id, "WORKING_SET") + manager.processRrIntervals(PolarHrMeasurement(hr = 148, contactDetected = true, rrIntervalsMs = listOf(800.0))) + workoutRepo.saveSession(WorkoutSession(id = set1Id, timestamp = simulatedTime)) + manager.flushSessionTelemetry(set1Id) + assertNull(manager.currentRecordingSessionId, "flushSessionTelemetry must clear activeSessionId once committed") + assertEquals(set1Id, manager.lastRecordedSessionId.value) + + // 2. Set 2 recording & flush + simulatedTime += 60_000L + manager.startSessionRecording(set2Id, "WORKING_SET") + manager.processRrIntervals(PolarHrMeasurement(hr = 165, contactDetected = true, rrIntervalsMs = listOf(720.0))) + workoutRepo.saveSession(WorkoutSession(id = set2Id, timestamp = simulatedTime)) + manager.flushSessionTelemetry(set2Id) + assertNull(manager.currentRecordingSessionId) + assertEquals(set2Id, manager.lastRecordedSessionId.value) + + // 3. Routine teardown transitions to Idle before RoutineComplete -> calls discardActiveRecording() + manager.discardActiveRecording() + + // Verify _lastRecordedSessionId and Set 2 summary were NOT wiped! + assertEquals(set2Id, manager.lastRecordedSessionId.value, "discardActiveRecording after flush must not wipe lastRecordedSessionId") + val summaryAfterIdle = manager.getSessionSummary(set2Id) + assertNotNull(summaryAfterIdle, "Set 2 summary must survive Idle discardActiveRecording") + assertEquals(165, summaryAfterIdle.avgHeartRateBpm) + + // Verify an earlier set ID (set1Id) is rejected so Set 1's summary cannot be overwritten by Set 2's in-memory sessionHrs + manager.triggerCooldown(set1Id, forceRestart = true) + assertFalse(manager.isCooldownActive.value, "triggerCooldown for an earlier set ID must be rejected") + + // 4. User clicks "Start Cooldown" on RoutineCompleteScreen + manager.triggerCooldown(manager.lastRecordedSessionId.value, forceRestart = true) + testScheduler.runCurrent() + assertTrue(manager.isCooldownActive.value, "Cooldown must be active after clicking Start Cooldown") + assertEquals(set2Id, manager.activeCooldownSessionId.value) + assertEquals(165, manager.cooldownStartHeartRate.value) + + // 5. Feed POST_WORKOUT_COOLDOWN beats at 65s and 300s + simulatedTime += 65_000L + manager.processRrIntervals(PolarHrMeasurement(hr = 135, contactDetected = true, rrIntervalsMs = listOf(850.0))) + testScheduler.advanceTimeBy(1500L) + assertEquals(30, manager.cooldownHrr60.value, "HRR60 should be 165 - 135 = 30 bpm") + + simulatedTime += 240_000L + manager.processRrIntervals(PolarHrMeasurement(hr = 110, contactDetected = true, rrIntervalsMs = listOf(920.0))) + testScheduler.advanceTimeBy(1500L) + + assertFalse(manager.isCooldownActive.value, "Cooldown should complete at 300s") + assertEquals(55, manager.cooldownHrr300.value, "HRR300 should be 165 - 110 = 55 bpm") + + // Verify POST_WORKOUT_COOLDOWN beats and HRR summary persisted to Set 2 row in SQLite + val set2Beats = database.phoenixDatabaseQueries.selectPolarBeatSamplesBySession(set2Id).executeAsList() + assertTrue(set2Beats.any { it.phase == "POST_WORKOUT_COOLDOWN" }, "POST_WORKOUT_COOLDOWN beats must persist to Set 2") + val set2Session = database.phoenixDatabaseQueries.selectSessionById(set2Id).executeAsOne() + assertEquals(30L, set2Session.hrr60Bpm) + assertEquals(55L, set2Session.hrr300Bpm) + } + + @Test + fun `cooldown preserves committed workout statistics and only completed HRR windows`() = runTest { + var nowMs = 100_000L + val manager = PolarH10BleManager(scope = backgroundScope, timeSource = { nowMs }) + manager.startSessionRecording("frozen") + manager.processRrIntervals(PolarHrMeasurement(140, true, rrIntervalsMs = listOf(800.0, 810.0))) + manager.processRrIntervals(PolarHrMeasurement(160, true, rrIntervalsMs = listOf(700.0))) + manager.flushSessionTelemetry("frozen") + val committed = assertNotNull(manager.getSessionSummary("frozen")) + assertEquals(150, committed.avgHeartRateBpm) + manager.triggerCooldown("frozen") + nowMs += 10_000L + manager.processRrIntervals(PolarHrMeasurement(120, true, rrIntervalsMs = listOf(1000.0))) + manager.stopCooldown() + assertEquals(committed, manager.getSessionSummary("frozen")) + assertNull(manager.cooldownHrr60.value) + assertNull(manager.cooldownHrr300.value) + manager.triggerCooldown("frozen", forceRestart = true) + nowMs += 65_000L + manager.processRrIntervals(PolarHrMeasurement(100, true, rrIntervalsMs = listOf(1100.0))) + manager.stopCooldown() + assertEquals(20, manager.cooldownHrr60.value) + assertNull(manager.cooldownHrr300.value) + assertEquals(committed.copy(hrr60Bpm = 20), manager.getSessionSummary("frozen")) + } + + @Test + fun `transient contact loss preserves cooldown and recovery buffers`() = runTest { + val advertisement = CompositionAdvertisement(name = "Polar H10 contact debounce") + val fake = CompositionGattFake(advertisement, StandardTestDispatcher(testScheduler)) + val manager = PolarH10BleManager( + scope = backgroundScope, + timeSource = { testScheduler.currentTime + 100_000L }, + advertisements = { flowOf(advertisement) }, + createPeripheral = { fake }, + ) + manager.startScanning() + runCurrent() + assertTrue(manager.connect(ScannedDevice("Polar H10 contact debounce", advertisement.identifier.toString())).isSuccess) + runCurrent() + manager.startSessionRecording("debounce") + manager.processRrIntervals(PolarHrMeasurement(150, true, rrIntervalsMs = listOf(800.0))) + manager.triggerCooldown("debounce") + manager.processRrIntervals(PolarHrMeasurement(130, true, rrIntervalsMs = listOf(900.0))) + manager.processRrIntervals(PolarHrMeasurement(0, false, rrIntervalsMs = emptyList())) + runCurrent() + advanceTimeBy(1000) + runCurrent() + assertTrue(manager.isCooldownActive.value, "Watchdog and cooldown ticks must honor transient contact debounce") + assertNull(manager.hrMeasurement.value) + manager.processRrIntervals(PolarHrMeasurement(128, true, rrIntervalsMs = listOf(950.0))) + advanceTimeBy(2500) + assertTrue(manager.isCooldownActive.value) + assertEquals(3, manager.getBufferedBeatSamplesForTesting().size) + manager.shutdown() + } + + @Test + fun `raw ECG storage requires consent and is bounded independently of live preview`() = runTest { + var nowMs = 100_000L + val settings = MapSettings() + val manager = PolarH10BleManager(settings = settings, scope = backgroundScope, timeSource = { nowMs }) + manager.startSessionRecording("raw") + manager.processRrIntervals(PolarHrMeasurement(140, true, rrIntervalsMs = listOf(800.0))) + val frame = PolarPmdMeasurement.Ecg(1L, listOf(100, 200, 300)) + manager.processPmdMeasurement(frame) + assertFalse(manager.isRawEcgStorageEnabled.value) + assertNotNull(manager.latestEcg.value) + assertTrue(manager.getBufferedEcgSegmentsForTesting().isEmpty()) + manager.setRawEcgStorageEnabled(true) + manager.processPmdMeasurement(frame) + assertEquals(1, manager.getBufferedEcgSegmentsForTesting().size) + nowMs += PolarH10BleManager.MAX_RAW_ECG_DURATION_MS + manager.processPmdMeasurement(frame.copy(sensorTimestampNs = 2L)) + assertEquals(1, manager.getBufferedEcgSegmentsForTesting().size) + assertEquals(2L, manager.latestEcg.value?.sensorTimestampNs) + manager.setRawEcgStorageEnabled(false) + assertTrue(manager.getBufferedEcgSegmentsForTesting().isEmpty()) + assertFalse(settings.getBoolean(PolarH10BleManager.KEY_RAW_ECG_STORAGE, true)) + manager.startSessionRecording("sample-cap") + manager.setRawEcgStorageEnabled(true) + val largeFrame = PolarPmdMeasurement.Ecg(3L, List(1000) { 100 + it % 100 }) + repeat(45) { manager.processPmdMeasurement(largeFrame) } + val samplesStored = manager.getBufferedEcgSegmentsForTesting().sumOf { it.samplesUvCsv.split(',').size } + assertEquals(39_000, samplesStored) + } + + @Test + fun `connect timeout cleans up peripheral and reports connection failure`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val advertisement = CompositionAdvertisement(name = "Polar H10 timeout") + val fake = CompositionGattFake(advertisement, dispatcher) + fake.connectGate = CompletableDeferred() + val manager = PolarH10BleManager( + scope = backgroundScope, + advertisements = { flowOf(advertisement) }, + createPeripheral = { fake }, + ) + manager.startScanning() + runCurrent() + val result = manager.connect(ScannedDevice("Polar H10 timeout", advertisement.identifier.toString())) + assertTrue(result.isFailure) + assertTrue(fake.disconnectCalls > 0) + assertFalse(manager.isConnected.value) + assertTrue(manager.connectionState.value is ConnectionState.Error) + manager.shutdown() + } + + @Test + fun `caller cancellation during connect cleans up and propagates cancellation`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val advertisement = CompositionAdvertisement(name = "Polar H10 cancelled") + val fake = CompositionGattFake(advertisement, dispatcher) + fake.connectGate = CompletableDeferred() + val manager = PolarH10BleManager( + scope = backgroundScope, + advertisements = { flowOf(advertisement) }, + createPeripheral = { fake }, + ) + manager.startScanning() + runCurrent() + val connect = launch { manager.connect(ScannedDevice("Polar H10 cancelled", advertisement.identifier.toString())) } + advanceTimeBy(151) + runCurrent() + connect.cancel() + connect.join() + assertTrue(connect.isCancelled) + assertTrue(fake.disconnectCalls > 0) + assertFalse(manager.isConnected.value) + assertEquals(ConnectionState.Disconnected, manager.connectionState.value) + manager.shutdown() + } + + @Test + fun `missing PMD services leave the connection state observer alive`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val advertisement = CompositionAdvertisement(name = "Polar H10 missing PMD") + val fake = CompositionGattFake(advertisement, dispatcher) + val manager = PolarH10BleManager( + scope = backgroundScope, + advertisements = { flowOf(advertisement) }, + createPeripheral = { fake }, + ) + manager.startScanning() + runCurrent() + assertTrue(manager.connect(ScannedDevice("Polar H10 missing PMD", advertisement.identifier.toString())).isSuccess) + runCurrent() + advanceTimeBy(BleConstants.GATT_OPERATION_TIMEOUT_MS + 1) + assertTrue(manager.isConnected.value) + fake.dropConnection() + runCurrent() + assertFalse(manager.isConnected.value) + assertEquals(ConnectionState.Disconnected, manager.connectionState.value) + manager.shutdown() + } + + @Test + fun `PMD subscription timeout is local and state observer survives`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val advertisement = CompositionAdvertisement(name = "Polar H10 stalled PMD") + val fake = CompositionGattFake(advertisement, dispatcher) + val peripheral = object : Peripheral by fake { + override fun observe(characteristic: Characteristic, onSubscription: suspend () -> Unit): Flow = flow { + awaitCancellation() + } + } + val manager = PolarH10BleManager( + scope = backgroundScope, advertisements = { flowOf(advertisement) }, createPeripheral = { peripheral }, + ) + manager.startScanning() + runCurrent() + assertTrue(manager.connect(ScannedDevice("Polar H10 stalled PMD", advertisement.identifier.toString())).isSuccess) + advanceTimeBy(BleConstants.GATT_OPERATION_TIMEOUT_MS + 1) + runCurrent() + assertTrue(manager.isConnected.value) + fake.dropConnection() + runCurrent() + assertEquals(ConnectionState.Disconnected, manager.connectionState.value) + manager.shutdown() + } + + @Test + fun `ECG start requires successful indication and catches synchronous write response`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val advertisement = CompositionAdvertisement(name = "Polar H10 control point") + val fake = CompositionGattFake(advertisement, dispatcher) + val responses = MutableSharedFlow() + val writtenOperations = mutableListOf() + var responseStatus: Int? = null + val peripheral = object : Peripheral by fake { + override fun observe(characteristic: Characteristic, onSubscription: suspend () -> Unit): Flow = flow { + onSubscription() + if (characteristic.characteristicUuid == BleConstants.Polar.pmdControlPointCharacteristic.characteristicUuid) { + emitAll(responses) + } else { + awaitCancellation() + } + } + + override suspend fun write(characteristic: Characteristic, data: ByteArray, writeType: WriteType) { + writtenOperations.add(data[0]) + responseStatus?.let { responses.emit(byteArrayOf(0xF0.toByte(), data[0], data[1], it.toByte())) } + } + } + val manager = PolarH10BleManager( + scope = backgroundScope, advertisements = { flowOf(advertisement) }, createPeripheral = { peripheral }, + ) + manager.startScanning() + runCurrent() + assertTrue(manager.connect(ScannedDevice("Polar H10 control point", advertisement.identifier.toString())).isSuccess) + runCurrent() + assertTrue(manager.startEcgStream().isFailure) + assertFalse(manager.isEcgStreaming.value) + responseStatus = 1 + assertTrue(manager.startEcgStream().isFailure) + assertFalse(manager.isEcgStreaming.value) + responseStatus = 0 + assertTrue(manager.startEcgStream().isSuccess) + assertTrue(manager.isEcgStreaming.value) + manager.setAccStreamingForTesting(true) + manager.startSessionRecording("stream-owner") + manager.releaseTelemetryPreviewAsync() + runCurrent() + assertTrue(manager.isEcgStreaming.value) + assertTrue(manager.isAccStreaming.value) + assertFalse(writtenOperations.contains(0x03.toByte())) + manager.triggerCooldown("stream-owner") + manager.releaseTelemetryPreviewAsync() + runCurrent() + assertTrue(manager.isEcgStreaming.value) + assertFalse(writtenOperations.contains(0x03.toByte())) + manager.shutdown() + } + + @Test + fun `PMD packets cannot keep stale HR alive long enough to invent recovery`() = runTest { + var nowMs = 100_000L + val manager = PolarH10BleManager(scope = backgroundScope, timeSource = { nowMs }) + manager.startSessionRecording("stale-hr") + manager.processRrIntervals(PolarHrMeasurement(150, true, rrIntervalsMs = listOf(800.0))) + manager.triggerCooldown("stale-hr") + nowMs += 65_000L + manager.processPmdMeasurement(PolarPmdMeasurement.Ecg(1L, listOf(100, 200, 300))) + manager.checkPacketAbsenceTimeout(nowMs) + assertFalse(manager.isCooldownActive.value) + assertNull(manager.cooldownHrr60.value) + assertNull(manager.cooldownHrr300.value) + } +} + + diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParserTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParserTest.kt new file mode 100644 index 000000000..e2d382af4 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParserTest.kt @@ -0,0 +1,465 @@ +package com.devil.phoenixproject.data.ble + +import com.devil.phoenixproject.domain.model.PolarAccSample +import com.devil.phoenixproject.domain.model.PolarPmdMeasurement +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PolarProtocolParserTest { + + // ==================== Heart Rate (0x2A37) Tests ==================== + + @Test + fun `parsePolarHrMeasurement returns null for empty or truncated payload`() { + assertNull(parsePolarHrMeasurement(byteArrayOf())) + // Flag claims 16-bit HR (bit 0 = 1), but payload is only 2 bytes total + assertNull(parsePolarHrMeasurement(byteArrayOf(0x01, 0x48))) + // Flag claims energy present (bit 3 = 1), but energy bytes missing + assertNull(parsePolarHrMeasurement(byteArrayOf(0x08, 0x48))) + } + + @Test + fun `parsePolarHrMeasurement parses basic 8-bit HR with contact detected`() { + // Flags: 0x06 (bit 1=1, bit 2=1 -> contact detected), HR: 72 bpm + val data = byteArrayOf(0x06, 72) + val result = parsePolarHrMeasurement(data) + + assertNotNull(result) + assertEquals(72, result.hr) + assertTrue(result.contactDetected) + assertNull(result.energyExpendedKj) + assertTrue(result.rrIntervalsMs.isEmpty()) + } + + @Test + fun `parsePolarHrMeasurement handles contact status correctly`() { + // Flags: 0x04 (contact supported but NOT detected -> bits 1..2 = 2) + val notDetected = parsePolarHrMeasurement(byteArrayOf(0x04, 60)) + assertNotNull(notDetected) + assertFalse(notDetected.contactDetected) + + // Flags: 0x00 (contact not supported -> bits 1..2 = 0, but hr = 60 > 0 -> contact OK) + val notSupportedWithHr = parsePolarHrMeasurement(byteArrayOf(0x00, 60)) + assertNotNull(notSupportedWithHr) + assertTrue(notSupportedWithHr.contactDetected) + + // Flags: 0x00 (contact not supported, hr = 0 -> contact false) + val notSupportedZeroHr = parsePolarHrMeasurement(byteArrayOf(0x00, 0)) + assertNotNull(notSupportedZeroHr) + assertFalse(notSupportedZeroHr.contactDetected) + + // Flags: 0x02 (contact not supported -> bits 1..2 = 1, hr = 75 > 0 -> contact OK) + val altNotSupportedWithHr = parsePolarHrMeasurement(byteArrayOf(0x02, 75)) + assertNotNull(altNotSupportedWithHr) + assertTrue(altNotSupportedWithHr.contactDetected) + + // Flags: 0x06 (contact supported and detected -> bits 1..2 = 3) + val detected = parsePolarHrMeasurement(byteArrayOf(0x06, 60)) + assertNotNull(detected) + assertTrue(detected.contactDetected) + } + + @Test + fun `parsePolarHrMeasurement parses 16-bit HR`() { + // Flags: 0x01 (16-bit HR format) | 0x06 (contact detected) = 0x07 + // HR: 280 bpm (0x0118 -> LE [0x18, 0x01]) + val data = byteArrayOf(0x07, 0x18, 0x01) + val result = parsePolarHrMeasurement(data) + + assertNotNull(result) + assertEquals(280, result.hr) + assertTrue(result.contactDetected) + } + + @Test + fun `parsePolarHrMeasurement parses energy expended`() { + // Flags: 0x08 (energy present) | 0x06 (contact detected) = 0x0E + // HR: 80 bpm, Energy: 1500 kJ (0x05DC -> LE [0xDC, 0x05]) + val data = byteArrayOf(0x0E, 80, 0xDC.toByte(), 0x05) + val result = parsePolarHrMeasurement(data) + + assertNotNull(result) + assertEquals(80, result.hr) + assertEquals(1500, result.energyExpendedKj) + assertTrue(result.rrIntervalsMs.isEmpty()) + } + + @Test + fun `parsePolarHrMeasurement parses RR intervals accurately in milliseconds`() { + // Flags: 0x10 (RR present) | 0x06 (contact detected) = 0x16 + // HR: 60 bpm + // RR 1: 1024 raw units = exactly 1000.0 ms (0x0400 -> LE [0x00, 0x04]) + // RR 2: 819 raw units = (819 * 1000.0) / 1024.0 ≈ 799.8046875 ms (0x0333 -> LE [0x33, 0x03]) + val data = byteArrayOf( + 0x16, + 60, + 0x00, 0x04, + 0x33, 0x03, + ) + val result = parsePolarHrMeasurement(data) + + assertNotNull(result) + assertEquals(60, result.hr) + assertEquals(2, result.rrIntervalsMs.size) + assertEquals(1000.0, result.rrIntervalsMs[0], 0.001) + assertEquals((819 * 1000.0) / 1024.0, result.rrIntervalsMs[1], 0.001) + } + + @Test + fun `parsePolarHrMeasurement parses complete packet with 16-bit HR and energy and multiple RR intervals`() { + // Flags: 0x01 (16-bit HR) | 0x06 (contact detected) | 0x08 (energy) | 0x10 (RR) = 0x1F + // HR: 120 bpm (0x0078 -> [0x78, 0x00]) + // Energy: 250 kJ (0x00FA -> [0xFA, 0x00]) + // RR 1: 512 raw = 500.0 ms (0x0200 -> [0x00, 0x02]) + val data = byteArrayOf( + 0x1F, + 0x78, 0x00, + 0xFA.toByte(), 0x00, + 0x00, 0x02, + ) + val result = parsePolarHrMeasurement(data) + + assertNotNull(result) + assertEquals(120, result.hr) + assertTrue(result.contactDetected) + assertEquals(250, result.energyExpendedKj) + assertEquals(1, result.rrIntervalsMs.size) + assertEquals(500.0, result.rrIntervalsMs[0], 0.001) + } + + // ==================== Battery Level (0x2A19) Tests ==================== + + @Test + fun `parsePolarBatteryLevel parses valid percentage`() { + assertNull(parsePolarBatteryLevel(byteArrayOf())) + assertEquals(100, parsePolarBatteryLevel(byteArrayOf(100))) + assertEquals(85, parsePolarBatteryLevel(byteArrayOf(85))) + assertEquals(0, parsePolarBatteryLevel(byteArrayOf(0))) + // Clamping out-of-range values + assertEquals(100, parsePolarBatteryLevel(byteArrayOf(120))) + } + + // ==================== Bit Unpacking & 64-bit LE Tests ==================== + + @Test + fun `getInt64LE parses 64-bit little endian timestamp`() { + val data = byteArrayOf( + 0x00, // offset padding + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + ) + val expected = 0x0807060504030201L + assertEquals(expected, getInt64LE(data, 1)) + } + + @Test + fun `readSignedBits parses positive and negative signed integers across bit boundaries`() { + // Byte 0: 0xFA (1111 1010) + // 4-bit signed: + // bits 0..3: 1010b = -6 + // bits 4..7: 1111b = -1 + val data = byteArrayOf(0xFA.toByte()) + assertEquals(-6, readSignedBits(data, 0, 0, 4)) + assertEquals(-1, readSignedBits(data, 0, 4, 4)) + + // 8-bit signed: + assertEquals(-6, readSignedBits(byteArrayOf(0xFA.toByte()), 0, 0, 8)) + assertEquals(125, readSignedBits(byteArrayOf(125), 0, 0, 8)) + + // 10-bit signed spanning across byte boundaries: + // Value: -5 (10-bit: 0x3FB -> 11 1111 1011b) + // LE bytes: Byte 0 = 0xFB (1111 1011b), Byte 1 = 0x03 (0000 0011b) + val tenBitData = byteArrayOf(0xFB.toByte(), 0x03) + assertEquals(-5, readSignedBits(tenBitData, 0, 0, 10)) + } + + // ==================== PMD ECG (FB005C82) Tests ==================== + + @Test + fun `parsePolarPmdData rejects packets with invalid length or unknown type`() { + // Less than 10 bytes + assertNull(parsePolarPmdData(ByteArray(9))) + // Unknown measurement type 0xFF + val unknownType = ByteArray(10).apply { this[0] = 0xFF.toByte() } + assertNull(parsePolarPmdData(unknownType)) + } + + @Test + fun `parsePolarPmdData parses uncompressed 24-bit ECG samples in microvolts`() { + // Type 0x00 (ECG), Timestamp: 1000_000_000 ns (0x3B9ACA00), FrameType: 0x00 + // Samples: + // Sample 0: 0 µV ([0x00, 0x00, 0x00]) + // Sample 1: +1000 µV (0x0003E8 -> [0xE8, 0x03, 0x00]) + // Sample 2: -1000 µV (24-bit two's complement: 0x1000000 - 1000 = 0xFFFF18 -> [0x18, 0xFF, 0xFF]) + val bytes = ByteArray(10 + 9).apply { + this[0] = 0x00 // ECG + // Timestamp ns: 1000 ns (0x00000000000003E8) + this[1] = 0xE8.toByte() + this[2] = 0x03 + // FrameType: 0x00 (uncompressed) + this[9] = 0x00 + + // Sample 0: 0 µV + this[10] = 0x00 + this[11] = 0x00 + this[12] = 0x00 + + // Sample 1: +1000 µV + this[13] = 0xE8.toByte() + this[14] = 0x03 + this[15] = 0x00 + + // Sample 2: -1000 µV + this[16] = 0x18 + this[17] = 0xFC.toByte() + this[18] = 0xFF.toByte() + } + + val result = parsePolarPmdData(bytes) + assertNotNull(result) + val ecg = assertIs(result) + assertEquals(1000L, ecg.sensorTimestampNs) + assertEquals(3, ecg.samplesMicrovolts.size) + assertEquals(0, ecg.samplesMicrovolts[0]) + assertEquals(1000, ecg.samplesMicrovolts[1]) + assertEquals(-1000, ecg.samplesMicrovolts[2]) + } + + // ==================== PMD ACC (FB005C82) Tests ==================== + + @Test + fun `parsePolarPmdData parses uncompressed 16-bit ACC samples`() { + // Type 0x02 (ACC), Timestamp: 5000 ns, FrameType: 0x01 + // Sample 0: X=100, Y=-200, Z=980 (1g down) + val bytes = ByteArray(10 + 6).apply { + this[0] = 0x02 // ACC + this[1] = 0x88.toByte() + this[2] = 0x13 // 5000L = 0x1388 + this[9] = 0x01 + + // X = 100 (0x0064) + this[10] = 0x64 + this[11] = 0x00 + // Y = -200 (0xFF38) + this[12] = 0x38 + this[13] = 0xFF.toByte() + // Z = 980 (0x03D4) + this[14] = 0xD4.toByte() + this[15] = 0x03 + } + + val result = parsePolarPmdData(bytes) + assertNotNull(result) + val acc = assertIs(result) + assertEquals(5000L, acc.sensorTimestampNs) + assertEquals(1, acc.samples.size) + assertEquals(PolarAccSample(xMg = 100, yMg = -200, zMg = 980), acc.samples[0]) + } + + @Test + fun `parsePolarPmdData parses delta-compressed ACC samples`() { + // Type 0x02 (ACC), Timestamp: 2000L, FrameType: 0x80 (delta-compressed) + // Reference sample: X=1000, Y=0, Z=-1000 + // Delta size: 8 bits + // Sample count: 2 delta samples + // Delta 1: dX = 5, dY = -10, dZ = 20 -> sample 1: (1005, -10, -980) + // Delta 2: dX = -5, dY = 15, dZ = -20 -> sample 2: (1000, 5, -1000) + val bytes = ByteArray(18 + 6).apply { + this[0] = 0x02 + this[1] = 0xD0.toByte() + this[2] = 0x07 // 2000L = 0x07D0 + this[9] = 0x80.toByte() // delta-compressed + + // Ref X = 1000 (0x03E8) + this[10] = 0xE8.toByte() + this[11] = 0x03 + // Ref Y = 0 (0x0000) + this[12] = 0x00 + this[13] = 0x00 + // Ref Z = -1000 (0xFC18) + this[14] = 0x18 + this[15] = 0xFC.toByte() + + this[16] = 8 // deltaSizeBits = 8 + this[17] = 2 // sampleCount = 2 + + // Deltas sample 1: dX=5, dY=-10, dZ=20 + this[18] = 5 + this[19] = (-10).toByte() + this[20] = 20 + + // Deltas sample 2: dX=-5, dY=15, dZ=-20 + this[21] = (-5).toByte() + this[22] = 15 + this[23] = (-20).toByte() + } + + val result = parsePolarPmdData(bytes) + assertNotNull(result) + val acc = assertIs(result) + assertEquals(2000L, acc.sensorTimestampNs) + // Base sample + 2 delta samples = 3 samples total + assertEquals(3, acc.samples.size) + assertEquals(PolarAccSample(1000, 0, -1000), acc.samples[0]) + assertEquals(PolarAccSample(1005, -10, -980), acc.samples[1]) + assertEquals(PolarAccSample(1000, 5, -1000), acc.samples[2]) + } + + @Test + fun `parsePolarPmdData handles delta-compressed ACC with zero deltas`() { + val bytes = ByteArray(18).apply { + this[0] = 0x02 + this[9] = 0x80.toByte() + // Ref X=50, Y=50, Z=50 + this[10] = 50; this[11] = 0 + this[12] = 50; this[13] = 0 + this[14] = 50; this[15] = 0 + this[16] = 0 // deltaSizeBits = 0 + this[17] = 0 // sampleCount = 0 + } + + val result = parsePolarPmdData(bytes) + assertNotNull(result) + val acc = assertIs(result) + assertEquals(1, acc.samples.size) + assertEquals(PolarAccSample(50, 50, 50), acc.samples[0]) + } + + @Test + fun `compressed ACC decodes every byte padded block using the preceding sample`() { + val packet = compressedAccPacket() + byteArrayOf(4, 1, 0xE1.toByte(), 0x03, 8, 1, -5, 7, -9) + + val result = assertIs(parsePolarPmdData(packet)) + + assertEquals( + listOf(PolarAccSample(50, 50, 50), PolarAccSample(51, 48, 53), PolarAccSample(46, 55, 44)), + result.samples, + ) + } + + @Test + fun `compressed ACC handles reference only and empty blocks without losing later samples`() { + val referenceOnly = assertIs(parsePolarPmdData(compressedAccPacket())) + assertEquals(listOf(PolarAccSample(50, 50, 50)), referenceOnly.samples) + + val packet = compressedAccPacket() + byteArrayOf(0, 0, 8, 0, 8, 1, 1, -1, 2) + val result = assertIs(parsePolarPmdData(packet)) + + assertEquals( + listOf( + PolarAccSample(50, 50, 50), + PolarAccSample(51, 49, 52), + ), + result.samples, + ) + } + + @Test + fun `compressed ACC rejects incomplete references headers payloads and unsupported delta widths`() { + val reference = compressedAccPacket() + val firstBlock = byteArrayOf(4, 1, 0xE1.toByte(), 0x03) + + assertNull(parsePolarPmdData(reference.copyOf(15))) + assertNull(parsePolarPmdData(reference + byteArrayOf(4))) + assertNull(parsePolarPmdData(reference + byteArrayOf(4, 1, 0xE1.toByte()))) + assertNull(parsePolarPmdData(reference + firstBlock + byteArrayOf(8))) + assertNull(parsePolarPmdData(reference + firstBlock + byteArrayOf(8, 1, 1, 2))) + assertNull(parsePolarPmdData(reference + byteArrayOf(17, 1))) + assertNull(parsePolarPmdData(reference + byteArrayOf(0, 1))) + assertNull(parsePolarPmdData(reference + byteArrayOf(0xFF.toByte(), 1))) + } + + @Test + fun `uncompressed ACC frame type zero decodes signed eight bit triplets`() { + val header = ByteArray(10).apply { this[0] = 0x02 } + + val result = assertIs( + parsePolarPmdData(header + byteArrayOf(127, -128, -1, 0, 25, -25)), + ) + + assertEquals(listOf(PolarAccSample(127, -128, -1), PolarAccSample(0, 25, -25)), result.samples) + } + + @Test + fun `parsePolarPmdData parses uncompressed 16-bit ACC samples with frameType 0x01`() { + // Polar H10 16-bit ACC frames use subType 0x01 (bit 7 = 0 for uncompressed) + val bytes = ByteArray(10 + 6).apply { + this[0] = 0x02 // ACC + this[1] = 0x88.toByte() + this[2] = 0x13 // 5000L + this[9] = 0x01 // uncompressed with subType 0x01 + + // X = 100 (0x0064) + this[10] = 0x64 + this[11] = 0x00 + // Y = -200 (0xFF38) + this[12] = 0x38 + this[13] = 0xFF.toByte() + // Z = 980 (0x03D4) + this[14] = 0xD4.toByte() + this[15] = 0x03 + } + + val result = parsePolarPmdData(bytes) + assertNotNull(result) + val acc = assertIs(result) + assertEquals(5000L, acc.sensorTimestampNs) + assertEquals(1, acc.samples.size) + assertEquals(PolarAccSample(xMg = 100, yMg = -200, zMg = 980), acc.samples[0]) + } + + @Test + fun `parsePolarPmdData parses delta-compressed ACC samples with frameType 0x81`() { + // Polar H10 delta-compressed 16-bit ACC frames use subType 0x01 with bit 7 = 1 -> 0x81 + val bytes = ByteArray(18 + 6).apply { + this[0] = 0x02 // ACC + this[1] = 0xD0.toByte() + this[2] = 0x07 // 2000L + this[9] = 0x81.toByte() // delta-compressed (0x80 | 0x01) + + // Ref X = 1000 (0x03E8) + this[10] = 0xE8.toByte() + this[11] = 0x03 + // Ref Y = 0 (0x0000) + this[12] = 0x00 + this[13] = 0x00 + // Ref Z = -1000 (0xFC18) + this[14] = 0x18 + this[15] = 0xFC.toByte() + + this[16] = 8 // deltaSizeBits = 8 + this[17] = 2 // sampleCount = 2 + + // Deltas sample 1: dX=5, dY=-10, dZ=20 + this[18] = 5 + this[19] = (-10).toByte() + this[20] = 20 + + // Deltas sample 2: dX=-5, dY=15, dZ=-20 + this[21] = (-5).toByte() + this[22] = 15 + this[23] = (-20).toByte() + } + + val result = parsePolarPmdData(bytes) + assertNotNull(result) + val acc = assertIs(result) + assertEquals(2000L, acc.sensorTimestampNs) + assertEquals(3, acc.samples.size) + assertEquals(PolarAccSample(1000, 0, -1000), acc.samples[0]) + assertEquals(PolarAccSample(1005, -10, -980), acc.samples[1]) + assertEquals(PolarAccSample(1000, 5, -1000), acc.samples[2]) + } + + private fun compressedAccPacket(): ByteArray = ByteArray(16).apply { + this[0] = 0x02 + this[9] = 0x81.toByte() + this[10] = 50 + this[12] = 50 + this[14] = 50 + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/ManualStopStartTimeRaceTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/ManualStopStartTimeRaceTest.kt index dc205b720..52eae2486 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/ManualStopStartTimeRaceTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/ManualStopStartTimeRaceTest.kt @@ -1,6 +1,8 @@ package com.devil.phoenixproject.presentation.manager +import com.devil.phoenixproject.data.ble.PolarH10BleManager import com.devil.phoenixproject.domain.model.Exercise +import com.devil.phoenixproject.domain.model.PolarHrMeasurement import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.RepCount import com.devil.phoenixproject.domain.model.Routine @@ -76,6 +78,32 @@ class ManualStopStartTimeRaceTest { harness.coordinator.collectedMetrics.clear() } + @Test + fun manualStopWithoutALeasePreservesTheRecordedPolarSummary() = runTest { + val polarManager = PolarH10BleManager(scope = backgroundScope) + val harness = DWSMTestHarness(this, polarBleManager = polarManager) + try { + runASetThenEndTheWorkout(harness) + polarManager.startSessionRecording("manual-polar", phase = "WORKING_SET") + polarManager.processRrIntervals(PolarHrMeasurement(120, true, rrIntervalsMs = listOf(500.0))) + polarManager.processRrIntervals(PolarHrMeasurement(100, true, rrIntervalsMs = listOf(600.0))) + harness.coordinator._repCount.value = RepCount(workingReps = REPS, totalReps = REPS, isWarmupComplete = true) + harness.coordinator.workoutStartTime = harness.nowMs - 10_000 + + harness.dwsm.stopWorkout() + advanceUntilIdle() + + val saved = harness.fakeWorkoutRepo.allSessions().single { it.totalReps == REPS } + assertEquals(110, saved.avgHeartRateBpm) + assertEquals(120, saved.maxHeartRateBpm) + assertEquals(100, saved.minHeartRateBpm) + assertEquals(550f, saved.avgRrIntervalMs) + assertEquals(100f, saved.rmssdMs) + } finally { + harness.cleanup() + } + } + @Test fun aResetDuringTheInFlightManualStopSaveKeepsTheRealStartAndDuration() = runTest { val harness = DWSMTestHarness(this) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/Issue687WorkoutStartGateUiTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/Issue687WorkoutStartGateUiTest.kt index 55dd968d3..5c65a9e1b 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/Issue687WorkoutStartGateUiTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/Issue687WorkoutStartGateUiTest.kt @@ -300,19 +300,19 @@ class Issue687WorkoutStartGateUiTest { assertTrue(presentation.showRecoveryActions) val workoutTab = source("presentation/screen/WorkoutTab.kt") - val connectedOnlyBranch = workoutTab.indexOf( - "if (connectionState is ConnectionState.Connected)", + val gatedContentBranch = workoutTab.indexOf( + "if (connectionState is ConnectionState.Connected || workoutState is WorkoutState.Completed)", ) val recoveryNotice = workoutTab.indexOf("WorkoutStartGateNotice(") - assertTrue(connectedOnlyBranch >= 0, "WorkoutTab connected-only content branch is missing") + assertTrue(gatedContentBranch >= 0, "WorkoutTab connected-or-completed content branch is missing") assertTrue(recoveryNotice >= 0, "WorkoutTab recovery notice is missing") assertTrue( - recoveryNotice < connectedOnlyBranch, + recoveryNotice < gatedContentBranch, "RecoveryRequired must keep Retry/Reconnect visible while $connectionState; " + - "WorkoutStartGateNotice is currently nested under connected-only content", + "WorkoutStartGateNotice is currently nested under gated content", ) - val alwaysAvailableRecoveryBlock = workoutTab.substring(recoveryNotice, connectedOnlyBranch) + val alwaysAvailableRecoveryBlock = workoutTab.substring(recoveryNotice, gatedContentBranch) assertContainsAll( alwaysAvailableRecoveryBlock, "onRetry = onRetryWorkoutTeardown", diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarRollingTelemetryUtilsTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarRollingTelemetryUtilsTest.kt new file mode 100644 index 000000000..5abccf1f4 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarRollingTelemetryUtilsTest.kt @@ -0,0 +1,90 @@ +package com.devil.phoenixproject.presentation.util + +import com.devil.phoenixproject.domain.model.PolarRrBeat +import com.devil.phoenixproject.util.PolarTelemetryUtils +import com.devil.phoenixproject.util.PolarTelemetryUtils.computeRollingRmssd +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PolarRollingTelemetryUtilsTest { + + @Test + fun testEmptyOrSingleBeatReturnsNull() { + val now = 100_000L + assertNull(computeRollingRmssd(emptyList(), currentTimeMs = now)) + assertNull(computeRollingRmssd(listOf(PolarRrBeat(800.0, now, 75)), currentTimeMs = now)) + } + + @Test + fun testKnownRmssdCalculation() { + val now = 100_000L + // Intervals: 800, 850 (+50), 820 (-30) + // Diffs: 50, -30 -> Squared: 2500, 900 -> Mean: 3400 / 2 = 1700 -> sqrt(1700) = 41.231 + val beats = listOf( + PolarRrBeat(800.0, now - 2000, 75), + PolarRrBeat(850.0, now - 1150, 71), + PolarRrBeat(820.0, now - 330, 73), + ) + + val rmssd = computeRollingRmssd(beats, windowMs = 60_000L, currentTimeMs = now) + assertTrue(rmssd != null) + assertEquals(41.23, rmssd, 0.01) + assertEquals("41 ms", PolarTelemetryUtils.formatMs(rmssd)) + } + + @Test + fun testSlidingWindowExcludesOldBeats() { + val now = 100_000L + // Beat at now - 70_000 is outside 60s window + val beats = listOf( + PolarRrBeat(1200.0, now - 70_000, 50), + PolarRrBeat(800.0, now - 1000, 75), + PolarRrBeat(850.0, now - 150, 71), + ) + + // Only 2 beats are within window: 800 and 850 (diff 50) -> sqrt(2500) = 50.0 + val rmssd = computeRollingRmssd(beats, windowMs = 60_000L, currentTimeMs = now) + assertTrue(rmssd != null) + assertEquals(50.0, rmssd, 0.01) + } + + @Test + fun testOutlierAndGappedBeatsAreFiltered() { + val now = 100_000L + val beats = listOf( + // Beat 1 and 2: huge gap of 10 seconds (dropped connection / dropped packet) + PolarRrBeat(800.0, now - 12_000, 75), + PolarRrBeat(820.0, now - 2_000, 73), + // Beat 3: valid time gap (1000ms), diff = +20 -> squared = 400 + PolarRrBeat(840.0, now - 1_000, 71), + ) + + val rmssd = computeRollingRmssd(beats, windowMs = 60_000L, currentTimeMs = now) + assertTrue(rmssd != null) + // Only pair (820, 840) is within valid gap tolerance + assertEquals(20.0, rmssd, 0.01) + } + + @Test + fun testDoubledRrIntervalArtifactIsRejected() { + val now = 100_000L + val beats = listOf( + PolarRrBeat(850.0, now - 3500, 71), + // Dropped R-peak / strap slip doubles the RR interval to 1700ms + PolarRrBeat(1700.0, now - 1800, 35), + // Recovery back to normal RR + PolarRrBeat(850.0, now - 950, 71), + // Next valid consecutive beat + PolarRrBeat(870.0, now - 80, 69), + ) + + val rmssd = computeRollingRmssd(beats, windowMs = 60_000L, currentTimeMs = now) + assertTrue(rmssd != null) + // (850, 1700) delta = 850 > 1700 * 0.25 (425) -> REJECTED + // (1700, 850) delta = 850 > 1700 * 0.25 (425) -> REJECTED + // Only (850, 870) delta = 20 <= 870 * 0.25 (217.5) -> ACCEPTED: sqrt(400) = 20.0 + assertEquals(20.0, rmssd, 0.01) + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/DWSMTestHarness.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/DWSMTestHarness.kt index 8e0988611..aa49f99d4 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/DWSMTestHarness.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/DWSMTestHarness.kt @@ -1,6 +1,7 @@ package com.devil.phoenixproject.testutil import com.devil.phoenixproject.data.preferences.InMemoryRecentJustLiftExerciseStore +import com.devil.phoenixproject.data.ble.PolarH10BleManager import com.devil.phoenixproject.data.repository.ActiveProfileContext import com.devil.phoenixproject.data.repository.ActiveWorkoutRuntimeAttributionEnvelope import com.devil.phoenixproject.data.repository.ActiveWorkoutRuntimeDiscoveryResult @@ -259,6 +260,7 @@ class FakeActiveWorkoutRuntimeRepository : ActiveWorkoutRuntimeRepository { internal class DWSMTestHarness( val testScope: TestScope, workoutRepositoryOverride: WorkoutRepository? = null, + polarBleManager: PolarH10BleManager? = null, completedSetRepositoryOverride: CompletedSetRepository? = null, biomechanicsDispatcher: CoroutineDispatcher = Dispatchers.Default, biomechanicsRepProcessor: BiomechanicsRepProcessor = BiomechanicsRepProcessor.Default, @@ -422,6 +424,7 @@ internal class DWSMTestHarness( wallClockMillisProvider = wallClockMillisProvider ?: { nowMs }, machineSafetyCoordinator = machineSafetyCoordinator, recentJustLiftExerciseStore = recentJustLiftExerciseStore, + polarBleManager = polarBleManager, ) // BleConnectionManager receives errors via coordinator.bleErrorEvents (no circular dependency) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt index 99a416c0d..8d6102140 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt @@ -8,6 +8,9 @@ import com.devil.phoenixproject.data.repository.RoutineRecoveryItem import com.devil.phoenixproject.data.repository.WorkoutRepository import com.devil.phoenixproject.domain.model.BiomechanicsRepResult import com.devil.phoenixproject.domain.model.CompletedSet +import com.devil.phoenixproject.domain.model.PolarBeatSample +import com.devil.phoenixproject.domain.model.PolarEcgSegment +import com.devil.phoenixproject.domain.model.PolarMetricSample import com.devil.phoenixproject.domain.model.RepMetricData import com.devil.phoenixproject.domain.model.Routine import com.devil.phoenixproject.domain.model.RoutineGroup @@ -393,4 +396,16 @@ class FakeWorkoutRepository : WorkoutRepository { } .map { it.exerciseId!! } .distinct() + + var polarBeatSamplesBySession: (String) -> List = { emptyList() } + override suspend fun getPolarBeatSamplesBySession(sessionId: String): List = + polarBeatSamplesBySession(sessionId) + + var polarEcgSegmentsBySession: (String) -> List = { emptyList() } + override suspend fun getPolarEcgSegmentsBySession(sessionId: String): List = + polarEcgSegmentsBySession(sessionId) + + var metricSamplesBySession: (String) -> List = { emptyList() } + override suspend fun getMetricSamplesBySession(sessionId: String): List = + metricSamplesBySession(sessionId) } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BackupSerializationTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BackupSerializationTest.kt index bd28cfe15..0c2a8b3c5 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BackupSerializationTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BackupSerializationTest.kt @@ -442,4 +442,124 @@ class BackupSerializationTest { assertEquals(null, legacy.routineExerciseId) assertEquals(1, legacy.attemptNumber) } + + @Test + fun polarTelemetryAndSamplesRoundTrip() { + val original = BackupData( + version = CURRENT_BACKUP_VERSION, + exportedAt = "2026-10-20T12:00:00Z", + appVersion = "1.0.4", + data = BackupContent( + workoutSessions = listOf( + WorkoutSessionBackup( + id = "session-polar-1", + timestamp = 1700000000000L, + mode = "OldSchool", + targetReps = 8, + weightPerCableKg = 30f, + progressionKg = 0f, + duration = 60000L, + totalReps = 8, + warmupReps = 0, + workingReps = 8, + isJustLift = false, + stopAtTop = false, + exerciseName = "Bench Press", + avgHeartRateBpm = 142, + maxHeartRateBpm = 168, + minHeartRateBpm = 95, + avgRrIntervalMs = 750.5f, + rmssdMs = 45.2f, + hrr60Bpm = 25, + hrr300Bpm = 50, + maxTorsoPitchDeg = 32.5f, + maxTorsoRollDeg = 15.1f, + ), + ), + metricSamples = listOf( + MetricSampleBackup( + id = 1L, + sessionId = "session-polar-1", + timestamp = 1700000001000L, + position = 100f, + velocity = 200f, + load = 30f, + power = 60f, + heartRateBpm = 145, + rrIntervalMs = 740f, + torsoPitchDeg = 30f, + torsoRollDeg = 12f, + dynamicAccMg = 150, + chestAccXMg = 100, + chestAccYMg = 200, + chestAccZMg = 950, + ), + ), + polarBeatSamples = listOf( + PolarBeatSampleBackup( + id = 1L, + sessionId = "session-polar-1", + timestampMs = 1700000002000L, + phase = "WORKING_SET", + hrBpm = 145, + rrMs = 740.0f, + rmssdMs = 45.2f, + torsoPitchDeg = 30.0f, + torsoRollDeg = 12.0f, + dynamicAccMg = 150, + ), + ), + polarEcgSegments = listOf( + PolarEcgSegmentBackup( + id = 1L, + sessionId = "session-polar-1", + sensorTimestampNs = 1000000000L, + wallClockTimestampMs = 1700000000000L, + phase = "WORKING_SET", + samplesUvCsv = "100,105,110", + ), + ), + ), + ) + + val jsonString = json.encodeToString(original) + val deserialized = json.decodeFromString(jsonString) + + assertEquals(1, deserialized.data.workoutSessions.size) + val s = deserialized.data.workoutSessions[0] + assertEquals(142, s.avgHeartRateBpm) + assertEquals(168, s.maxHeartRateBpm) + assertEquals(95, s.minHeartRateBpm) + assertEquals(750.5f, s.avgRrIntervalMs) + assertEquals(45.2f, s.rmssdMs) + assertEquals(25, s.hrr60Bpm) + assertEquals(50, s.hrr300Bpm) + assertEquals(32.5f, s.maxTorsoPitchDeg) + assertEquals(15.1f, s.maxTorsoRollDeg) + + assertEquals(1, deserialized.data.metricSamples.size) + val m = deserialized.data.metricSamples[0] + assertEquals(145, m.heartRateBpm) + assertEquals(740f, m.rrIntervalMs) + assertEquals(30f, m.torsoPitchDeg) + assertEquals(12f, m.torsoRollDeg) + assertEquals(150, m.dynamicAccMg) + assertEquals(100, m.chestAccXMg) + assertEquals(200, m.chestAccYMg) + assertEquals(950, m.chestAccZMg) + + assertEquals(1, deserialized.data.polarBeatSamples.size) + val b = deserialized.data.polarBeatSamples[0] + assertEquals(1L, b.id) + assertEquals(740.0f, b.rrMs) + assertEquals(145, b.hrBpm) + assertEquals("WORKING_SET", b.phase) + assertEquals(45.2f, b.rmssdMs) + + assertEquals(1, deserialized.data.polarEcgSegments.size) + val e = deserialized.data.polarEcgSegments[0] + assertEquals(1L, e.id) + assertEquals("100,105,110", e.samplesUvCsv) + assertEquals(1000000000L, e.sensorTimestampNs) + } } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtilsTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtilsTest.kt new file mode 100644 index 000000000..164affcff --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtilsTest.kt @@ -0,0 +1,154 @@ +package com.devil.phoenixproject.util + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PolarTelemetryUtilsTest { + + private val toleranceDeg = 0.5 + + @Test + fun standingUprightProducesNearZeroPitchAndRollAndZeroDynamicAcc() { + // Upright: 1G pointing straight down along -X + val x = -1000 + val y = 0 + val z = 0 + + val metrics = PolarAccFilter().processSample(x, y, z) + + assertTrue(abs(metrics.pitchDeg) < toleranceDeg, "Standing upright pitch should be ~0°, got ${metrics.pitchDeg}") + assertTrue(abs(metrics.rollDeg) < toleranceDeg, "Standing upright roll should be ~0°, got ${metrics.rollDeg}") + assertEquals(0, metrics.dynamicAccMg, "Static upright dynamic acceleration should be 0 mG") + } + + @Test + fun fortyFiveDegreeForwardHingeProducesFortyFiveDegreesPitch() { + // 45° forward hinge: gravity pulls along -X (707 mG) and -Z (707 mG) + val x = -707 + val y = 0 + val z = -707 + + val metrics = PolarAccFilter().processSample(x, y, z) + + assertTrue(abs(metrics.pitchDeg - 45.0) < toleranceDeg, "45° forward hinge pitch should be ~45°, got ${metrics.pitchDeg}") + assertTrue(abs(metrics.rollDeg) < toleranceDeg, "45° forward hinge roll should be ~0°, got ${metrics.rollDeg}") + assertEquals(0, metrics.dynamicAccMg, "Static hinge dynamic acceleration should be ~0 mG") + } + + @Test + fun backwardLeanProducesNegativePitch() { + // Leaning backward: gravity pulls along +Z + val x = -707 + val y = 0 + val z = 707 + + val metrics = PolarAccFilter().processSample(x, y, z) + + assertTrue(abs(metrics.pitchDeg - (-45.0)) < toleranceDeg, "Backward lean pitch should be ~ -45°, got ${metrics.pitchDeg}") + } + + @Test + fun horizontalHingeProducesNinetyDegreesPitch() { + // 90° horizontal bend (torso parallel to ground): gravity entirely along -Z + val x = 0 + val y = 0 + val z = -1000 + + val metrics = PolarAccFilter().processSample(x, y, z) + + assertTrue(abs(metrics.pitchDeg - 90.0) < toleranceDeg, "Horizontal hinge pitch should be ~90°, got ${metrics.pitchDeg}") + assertTrue(abs(metrics.rollDeg) < toleranceDeg, "Horizontal hinge roll should be ~0°, got ${metrics.rollDeg}") + } + + @Test + fun lateralTiltProducesAccurateRoll() { + // 30° lateral tilt to right: Y = 500 mG, X = -866 mG, Z = 0 + val x = -866 + val y = 500 + val z = 0 + + val metrics = PolarAccFilter().processSample(x, y, z) + + assertTrue(abs(metrics.rollDeg - 30.0) < toleranceDeg, "Lateral tilt roll should be ~30°, got ${metrics.rollDeg}") + assertTrue(abs(metrics.pitchDeg) < toleranceDeg, "Lateral tilt pitch should be ~0°, got ${metrics.pitchDeg}") + assertEquals(0, metrics.dynamicAccMg, "Static roll dynamic acceleration should be 0 mG") + } + + @Test + fun dynamicAccMeasuresTrunkJerkAboveOneG() { + // Dynamic jerk/heave: total magnitude = 1500 mG (0.5G above static 1G) + val x = -1500.0 + val y = 0.0 + val z = 0.0 + + val dynamicAcc = PolarTelemetryUtils.calculateDynamicAccMg(x, y, z) + assertEquals(500, dynamicAcc, "Dynamic acceleration should be 500 mG") + } + + @Test + fun standingCalibrationTareCompensatesInitialAngle() { + // Sensor mounted with a natural 5° forward pitch when standing upright + val initialRawX = -996.19 + val initialRawY = 0.0 + val initialRawZ = -87.16 // ~5° angle with inverted Z + + val initialPitch = PolarTelemetryUtils.calculateTorsoPitchDeg(initialRawX, initialRawY, initialRawZ, tarePitchDeg = 0.0) + assertTrue(abs(initialPitch - 5.0) < toleranceDeg) + + // With tare applied + val taredPitch = PolarTelemetryUtils.calculateTorsoPitchDeg(initialRawX, initialRawY, initialRawZ, tarePitchDeg = initialPitch) + assertTrue(abs(taredPitch) < 0.001, "Tared standing pitch should be exactly 0°") + } + + @Test + fun lowPassFilterSmoothsSuccessiveSamples() { + val filter = PolarAccFilter(alpha = 0.2) + + // First sample initializes filter + val first = filter.processSample(-1000, 0, 0) + assertEquals(-1000.0, first.filteredXMg) + + // Sudden transient spike to -1500 + val second = filter.processSample(-1500, 0, 0) + // Expected: 0.2 * -1500 + 0.8 * -1000 = -300 - 800 = -1100.0 + assertEquals(-1100.0, second.filteredXMg) + + // Next spike sample + val third = filter.processSample(-1500, 0, 0) + // Expected: 0.2 * -1500 + 0.8 * -1100 = -300 - 880 = -1180.0 + assertEquals(-1180.0, third.filteredXMg) + } + + @Test + fun rmssdCalculationComputesAccurateRootMeanSquare() { + val rrs = listOf(800.0, 820.0, 810.0, 830.0) + // diffs: +20, -10, +20 + // squared diffs: 400, 100, 400 -> sum = 900 + // mean: 900 / 3 = 300 + // sqrt: sqrt(300) = 17.3205 + val rmssd = PolarTelemetryUtils.calculateRmssdMs(rrs) + assertTrue(rmssd != null && abs(rmssd - 17.32) < 0.05) + } + + @Test + fun rmssdCalculationRejectsDoubledRrAndArtifactSpikes() { + // Normal intervals with a single missed-beat / strap-slip spike (820 -> 1700 -> 810) + val rrsWithSpike = listOf(800.0, 820.0, 1700.0, 810.0, 830.0) + val rmssd = PolarTelemetryUtils.calculateRmssdMs(rrsWithSpike) + // Valid pairs: (800, 820) [diff 20], (810, 830) [diff 20] + // Mean squared diff: (400 + 400) / 2 = 400 -> sqrt = 20.0 + assertTrue(rmssd != null) + assertEquals(20.0, rmssd, 0.01) + } + + @Test + fun rmssdCalculationRejectsNonPhysiologicalIntervals() { + val nonPhysiological = listOf(100.0, 800.0, 820.0, 3000.0) + val rmssd = PolarTelemetryUtils.calculateRmssdMs(nonPhysiological) + // Only valid pair is (800, 820) [diff 20] -> sqrt(400 / 1) = 20.0 + assertTrue(rmssd != null) + assertEquals(20.0, rmssd, 0.01) + } +} diff --git a/shared/src/iosMain/kotlin/com/devil/phoenixproject/di/PlatformModule.ios.kt b/shared/src/iosMain/kotlin/com/devil/phoenixproject/di/PlatformModule.ios.kt index 289744bff..a326f2f8a 100644 --- a/shared/src/iosMain/kotlin/com/devil/phoenixproject/di/PlatformModule.ios.kt +++ b/shared/src/iosMain/kotlin/com/devil/phoenixproject/di/PlatformModule.ios.kt @@ -143,6 +143,7 @@ actual val platformModule: Module = module { machineSafetyCoordinator = get(), profileRecoveryActivityTracker = get(), recentJustLiftExerciseStore = get(), + polarBleManager = getOrNull(), ) } } diff --git a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt new file mode 100644 index 000000000..f8e8be3fd --- /dev/null +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt @@ -0,0 +1,479 @@ +package com.devil.phoenixproject.presentation.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.devil.phoenixproject.data.ble.PolarH10BleManager +import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.ui.theme.Spacing +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import kotlin.math.roundToInt + +@Composable +fun PolarH10SettingsCard( + modifier: Modifier = Modifier, + polarBleManager: PolarH10BleManager = koinInject(), +) { + val scope = rememberCoroutineScope() + val connectionState by polarBleManager.connectionState.collectAsState() + val scannedDevices by polarBleManager.scannedDevices.collectAsState() + val batteryLevel by polarBleManager.batteryLevel.collectAsState() + val hrMeasurement by polarBleManager.hrMeasurement.collectAsState() + + val rememberedAddress by polarBleManager.rememberedAddress.collectAsState() + val rememberedName by polarBleManager.rememberedName.collectAsState() + val isAutoReconnectEnabled by polarBleManager.isAutoReconnectEnabled.collectAsState() + val isRawEcgStorageEnabled by polarBleManager.isRawEcgStorageEnabled.collectAsState() + + val torsoPitchDeg by polarBleManager.torsoPitchDeg.collectAsState() + val torsoRollDeg by polarBleManager.torsoRollDeg.collectAsState() + val dynamicAccMg by polarBleManager.dynamicAccMg.collectAsState() + val isContactDetected by polarBleManager.isContactDetected.collectAsState() + + + + Card( + modifier = modifier + .fillMaxWidth() + .shadow(8.dp, MaterialTheme.shapes.medium), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), + shape = MaterialTheme.shapes.medium, + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.medium), + ) { + // Header Row + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(48.dp) + .shadow(8.dp, MaterialTheme.shapes.medium) + .background( + Brush.linearGradient( + colors = listOf(Color(0xFFE53935), Color(0xFFD81B60)), + ), + MaterialTheme.shapes.medium, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.Favorite, + contentDescription = "Polar H10", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(24.dp), + ) + } + Spacer(modifier = Modifier.width(Spacing.medium)) + Column { + Text( + "Polar H10 Sensor", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + when (connectionState) { + is ConnectionState.Connected -> "Connected" + is ConnectionState.Connecting -> "Connecting..." + is ConnectionState.Scanning -> "Scanning for sensors..." + is ConnectionState.Error -> "Connection error" + is ConnectionState.Disconnected -> "Disconnected" + }, + style = MaterialTheme.typography.bodySmall, + color = when (connectionState) { + is ConnectionState.Connected -> Color(0xFF00C853) + is ConnectionState.Scanning, is ConnectionState.Connecting -> MaterialTheme.colorScheme.primary + is ConnectionState.Error -> MaterialTheme.colorScheme.error + is ConnectionState.Disconnected -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + // Header status badge / action + if (connectionState is ConnectionState.Connected) { + OutlinedButton( + onClick = { scope.launch { polarBleManager.disconnect() } }, + ) { + Text("Disconnect", color = MaterialTheme.colorScheme.error) + } + } + } + + Spacer(modifier = Modifier.height(Spacing.medium)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Save raw ECG", style = MaterialTheme.typography.titleSmall) + Text( + "Off by default. Save up to 5 minutes per recording locally. Raw ECG is removed after 30 days or when its workout is deleted; a storage limit can remove older traces sooner. ECG is not synced to the portal.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = isRawEcgStorageEnabled, + onCheckedChange = polarBleManager::setRawEcgStorageEnabled, + ) + } + Spacer(modifier = Modifier.height(Spacing.medium)) + + // Body based on ConnectionState + when (val state = connectionState) { + is ConnectionState.Disconnected, is ConnectionState.Error -> { + Text( + "Connect your Polar H10 chest strap for real-time heart rate, R-R HRV intervals, 130 Hz ECG trace, and 50 Hz chest acceleration telemetry.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(Spacing.medium)) + + if (rememberedAddress != null) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.shapes.small, + ) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Remembered Sensor", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + Text( + rememberedName ?: "Polar H10", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + rememberedAddress ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedButton( + onClick = { polarBleManager.forgetRememberedDevice() }, + ) { + Text("Forget", color = MaterialTheme.colorScheme.error) + } + Button( + onClick = { scope.launch { polarBleManager.autoReconnectIfRemembered() } }, + ) { + Text("Reconnect") + } + } + } + } + Spacer(modifier = Modifier.height(Spacing.small)) + } + + Button( + onClick = { scope.launch { polarBleManager.startScanning() } }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Default.Refresh, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(if (rememberedAddress != null) "Scan for Other Sensors" else "Scan for Polar H10") + } + } + + is ConnectionState.Scanning -> { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(12.dp)) + Text( + "Looking for nearby Polar devices...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + OutlinedButton( + onClick = { scope.launch { polarBleManager.stopScanning() } }, + ) { + Text("Stop") + } + } + + Spacer(modifier = Modifier.height(Spacing.small)) + + if (scannedDevices.isEmpty()) { + Text( + "Ensure your Polar H10 strap is moistened and worn firmly on your chest.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + modifier = Modifier.padding(vertical = 8.dp), + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + scannedDevices.forEach { device -> + Row( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.shapes.small, + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + device.name.ifBlank { "Polar H10" }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + device.address, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Button( + onClick = { scope.launch { polarBleManager.connect(device) } }, + ) { + Text("Connect") + } + } + } + } + } + } + + is ConnectionState.Connecting -> { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(12.dp)) + Text( + "Establishing secure BLE connection...", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + is ConnectionState.Connected -> { + // Device info & metrics grid + Column( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.shapes.small, + ) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Device Name & Address + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + "Device: ${state.deviceName}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Battery: ${batteryLevel?.let { "$it%" } ?: "—"}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = if ((batteryLevel ?: 100) < 20) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + ) + } + + // Skin contact & live HR + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + val contact = isContactDetected + Icon( + if (contact) Icons.Default.CheckCircle else Icons.Default.Warning, + contentDescription = null, + tint = if (contact) Color(0xFF00C853) else Color(0xFFFFB300), + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + if (contact) "Skin Contact OK" else "Poor Skin Contact", + style = MaterialTheme.typography.bodySmall, + color = if (contact) Color(0xFF00C853) else Color(0xFFFFB300), + ) + } + + Text( + "Live HR: ${hrMeasurement?.hr?.let { "$it BPM" } ?: "—"}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Auto-reconnect on workout", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + "Remember this sensor for future sessions", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = isAutoReconnectEnabled, + onCheckedChange = { polarBleManager.setAutoReconnectEnabled(it) }, + ) + } + + } + + // Torso Biomechanics Posture Section + Spacer(modifier = Modifier.height(Spacing.small)) + Text( + "Torso Biomechanics (50 Hz ACC)", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.shapes.small, + ) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text("Pitch", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text = torsoPitchDeg?.let { "${(it * 10.0).roundToInt() / 10.0}°" } ?: "—", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + Column { + Text("Roll", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text = torsoRollDeg?.let { "${(it * 10.0).roundToInt() / 10.0}°" } ?: "—", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + Column { + Text("Dynamic Acc", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text = dynamicAccMg?.let { "$it mG" } ?: "—", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { polarBleManager.tareCurrentPosture() }, + modifier = Modifier.weight(1f), + ) { + Text("Tare Posture") + } + OutlinedButton( + onClick = { polarBleManager.resetPostureTare() }, + modifier = Modifier.weight(1f), + ) { + Text("Reset Tare") + } + } + } + } + } + } + } +} diff --git a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt new file mode 100644 index 000000000..4f6d90884 --- /dev/null +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt @@ -0,0 +1,385 @@ +package com.devil.phoenixproject.presentation.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.devil.phoenixproject.data.ble.PolarH10BleManager +import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.domain.model.PolarAccSample +import com.devil.phoenixproject.domain.model.PolarRrBeat +import com.devil.phoenixproject.domain.model.currentTimeMillis +import com.devil.phoenixproject.util.PolarTelemetryUtils +import com.devil.phoenixproject.util.PolarTelemetryUtils.computeRollingRmssd +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlin.math.roundToInt +import kotlin.math.sqrt + +private const val MAX_ECG_DISPLAY_SAMPLES = 260 // ~2 seconds at 130 Hz + +@Composable +fun PolarH10WorkoutStrip( + modifier: Modifier = Modifier, + polarBleManager: PolarH10BleManager? = null, +) { + if (polarBleManager == null) return + val connectionState by polarBleManager.connectionState.collectAsState() + val isConnected = connectionState is ConnectionState.Connected + + AnimatedVisibility( + visible = isConnected, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + PolarH10WorkoutStripContent( + modifier = modifier, + polarBleManager = polarBleManager, + ) + } +} + +@Composable +private fun PolarH10WorkoutStripContent( + modifier: Modifier = Modifier, + polarBleManager: PolarH10BleManager, +) { + val scope = rememberCoroutineScope() + val hrMeasurement by polarBleManager.hrMeasurement.collectAsState() + val isContactDetected by polarBleManager.isContactDetected.collectAsState() + val hasContact = hrMeasurement?.contactDetected == true && isContactDetected + + // Sliding window of RR beats for RMSSD calculation + val recentBeats = remember { mutableStateListOf() } + var rollingRmssd by remember { mutableStateOf(null) } + var latestRrMs by remember { mutableStateOf(null) } + + // Rolling ECG samples (µV) + val ecgSamples = remember { mutableStateListOf() } + + // Wipe display buffers cleanly when skin contact is lost + LaunchedEffect(hasContact) { + if (!hasContact) { + ecgSamples.clear() + recentBeats.clear() + latestRrMs = null + rollingRmssd = null + } + } + + // Latest accelerometer sample and filtered posture StateFlows + val latestAcc by polarBleManager.latestAcc.collectAsState() + val latestAccSample = latestAcc?.samples?.lastOrNull() + val torsoPitchDeg by polarBleManager.torsoPitchDeg.collectAsState() + val torsoRollDeg by polarBleManager.torsoRollDeg.collectAsState() + val dynamicAccMg by polarBleManager.dynamicAccMg.collectAsState() + + DisposableEffect(Unit) { + onDispose { + polarBleManager.releaseTelemetryPreviewAsync() + } + } + + // 2. Stream collectors + LaunchedEffect(Unit) { + polarBleManager.rrBeats.collect { beat -> + val now = currentTimeMillis() + latestRrMs = beat.rrIntervalMs + recentBeats.add(beat) + // Retain beats within sliding 60s window + while (recentBeats.isNotEmpty() && recentBeats.first().timestampMs < now - 60_000L) { + recentBeats.removeAt(0) + } + rollingRmssd = computeRollingRmssd(recentBeats, windowMs = 60_000L, currentTimeMs = now) + } + } + + LaunchedEffect(Unit) { + polarBleManager.ecgStream.collect { ecg -> + ecgSamples.addAll(ecg.samplesMicrovolts) + while (ecgSamples.size > MAX_ECG_DISPLAY_SAMPLES) { + ecgSamples.removeAt(0) + } + } + } + + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.medium, + tonalElevation = 4.dp, + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // Section 1: Heart Rate (BPM) + Contact Indicator + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(end = 8.dp), + ) { + Icon( + Icons.Default.Favorite, + contentDescription = "Polar Heart Rate", + tint = if (hasContact) Color(0xFFFF1744) else Color(0xFFFF9100), + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Column { + val hr = hrMeasurement?.hr ?: 0 + Text( + text = if (hr > 0) "$hr" else "—", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "BPM", + style = MaterialTheme.typography.labelSmall, + fontSize = 9.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Section 2: RR Interval & Rolling RMSSD + Column( + modifier = Modifier.padding(horizontal = 6.dp), + horizontalAlignment = Alignment.Start, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "RR: ", + style = MaterialTheme.typography.labelSmall, + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = PolarTelemetryUtils.formatMs(latestRrMs), + style = MaterialTheme.typography.labelSmall, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "RMSSD: ", + style = MaterialTheme.typography.labelSmall, + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = PolarTelemetryUtils.formatMs(rollingRmssd), + style = MaterialTheme.typography.labelSmall, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = Color(0xFF00E676), + ) + } + } + + // Section 3: 130 Hz Real-Time ECG Waveform Canvas + Box( + modifier = Modifier + .weight(1f) + .height(38.dp) + .padding(horizontal = 6.dp) + .clip(MaterialTheme.shapes.small) + .background(Color(0xFF0D1B2A)) + .border(0.5.dp, Color(0xFF1E3A5F), MaterialTheme.shapes.small), + contentAlignment = Alignment.Center, + ) { + EcgWaveformCanvas( + samples = ecgSamples, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(horizontal = 2.dp, vertical = 2.dp), + ) + } + + // Section 4: Live Chest Acceleration & 3D Torso Posture with 1-Tap 0° Tare + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(start = 4.dp), + ) { + Column( + horizontalAlignment = Alignment.End, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Hinge: ", + style = MaterialTheme.typography.labelSmall, + fontSize = 9.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = torsoPitchDeg?.let { "${(it * 10).roundToInt() / 10.0}°" } ?: "—", + style = MaterialTheme.typography.labelSmall, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = if (torsoPitchDeg != null) Color(0xFFFF9800) else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Tilt: ", + style = MaterialTheme.typography.labelSmall, + fontSize = 9.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = torsoRollDeg?.let { "${(it * 10).roundToInt() / 10.0}°" } ?: "—", + style = MaterialTheme.typography.labelSmall, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = if (torsoRollDeg != null) Color(0xFF00BCD4) else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = dynamicAccMg?.let { "$it mG" } ?: (latestAccSample?.let { acc -> + val magnitudeG = sqrt((acc.xMg.toDouble() * acc.xMg + acc.yMg.toDouble() * acc.yMg + acc.zMg.toDouble() * acc.zMg)) / 1000.0 + "${(magnitudeG * 100).roundToInt() / 100.0} G" + } ?: "—"), + style = MaterialTheme.typography.labelSmall, + fontSize = 8.sp, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Surface( + onClick = { polarBleManager.tareCurrentPosture() }, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.secondaryContainer, + border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)), + ) { + Box( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "0° Tare", + style = MaterialTheme.typography.labelSmall, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + } + } +} + +/** + * Real-time hardware-accelerated Canvas waveform plotting 130 Hz ECG samples (µV). + */ +@Composable +internal fun EcgWaveformCanvas( + samples: List, + modifier: Modifier = Modifier, +) { + Canvas(modifier = modifier) { + val width = size.width + val height = size.height + val midY = height / 2f + + // Draw center baseline grid line + drawLine( + color = Color(0xFF1B2A4A), + start = Offset(0f, midY), + end = Offset(width, midY), + strokeWidth = 1f, + ) + + if (samples.size < 2) return@Canvas + + // Dynamic autoscale: nominal range ±1500 µV + val maxAbsVal = samples.takeLast(130).maxOfOrNull { kotlin.math.abs(it) }?.toFloat()?.coerceAtLeast(800f) ?: 1500f + val scaleY = (height * 0.42f) / maxAbsVal + + val stepX = width / (MAX_ECG_DISPLAY_SAMPLES - 1).toFloat() + val path = Path() + + val startIndex = (MAX_ECG_DISPLAY_SAMPLES - samples.size).coerceAtLeast(0) + samples.forEachIndexed { i, sampleUv -> + val x = (startIndex + i) * stepX + val y = (midY - (sampleUv * scaleY)).coerceIn(1f, height - 1f) + if (i == 0) { + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + } + + drawPath( + path = path, + color = Color(0xFF00E5FF), + style = Stroke( + width = 1.5.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + } +} diff --git a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt new file mode 100644 index 000000000..91f33f30a --- /dev/null +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt @@ -0,0 +1,1633 @@ +package com.devil.phoenixproject.presentation.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import co.touchlab.kermit.Logger +import com.devil.phoenixproject.data.ble.PolarH10BleManager +import com.devil.phoenixproject.data.ble.PolarSessionSummary +import com.devil.phoenixproject.data.repository.WorkoutRepository +import com.devil.phoenixproject.domain.model.PolarMetricSample +import com.devil.phoenixproject.domain.model.PolarBeatSample +import com.devil.phoenixproject.domain.model.PolarEcgSegment +import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.ui.theme.Spacing +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Enhanced Polar H10 Session Analytics Card. + * + * Displays post-set and post-workout cardiac performance metrics (Avg/Max/Min HR, RMSSD, RR), + * 3D Torso Biomechanics (max hip-hinge pitch & lateral roll asymmetry), the 5-minute + * Heart Rate Recovery (HRR) cooldown protocol with real-time countdown, drop tracking, and control, + * plus 3 rich interactive Canvas visualizations: + * 1. Continuous Heart Rate & Recovery Timeline with color-coded phase bands + * 2. 3D Torso Biomechanics vs. Rep Cable Position + * 3. Saved 130 Hz ECG Waveform Scrubber + */ +@Composable +fun PolarSessionAnalyticsCard( + sessionId: String? = null, + session: WorkoutSession? = null, + showCooldown: Boolean = true, + modifier: Modifier = Modifier, + polarBleManager: PolarH10BleManager? = null, + workoutRepository: WorkoutRepository? = null, +) { + val connectionState = polarBleManager?.connectionState?.collectAsState()?.value ?: ConnectionState.Disconnected + val managerCooldownActive = polarBleManager?.isCooldownActive?.collectAsState()?.value ?: false + val cooldownSecondsRemaining = polarBleManager?.cooldownSecondsRemaining?.collectAsState()?.value ?: 0 + val cooldownStartHr = polarBleManager?.cooldownStartHeartRate?.collectAsState()?.value + val latestSummary = polarBleManager?.latestSessionSummary?.collectAsState()?.value + val hrMeasurement = polarBleManager?.hrMeasurement?.collectAsState()?.value + val activeCooldownSessionId = polarBleManager?.activeCooldownSessionId?.collectAsState()?.value + val lastRecordedSessionId = polarBleManager?.lastRecordedSessionId?.collectAsState()?.value + + val resolvedSessionId = sessionId ?: session?.id ?: if (showCooldown) { + activeCooldownSessionId + ?: lastRecordedSessionId + ?: sessionId + ?: session?.id + ?: polarBleManager?.currentRecordingSessionId + ?: latestSummary?.sessionId + } else null + val isCooldownActive = showCooldown && managerCooldownActive && + resolvedSessionId != null && resolvedSessionId == activeCooldownSessionId + val isCurrentSession = showCooldown && resolvedSessionId != null && + (resolvedSessionId == lastRecordedSessionId || resolvedSessionId == polarBleManager?.currentRecordingSessionId) + val fallbackSummary = remember(session, resolvedSessionId) { + session?.takeIf { it.id == resolvedSessionId }?.let { s -> + if (s.avgHeartRateBpm != null || s.maxHeartRateBpm != null || s.minHeartRateBpm != null || + s.avgRrIntervalMs != null || s.rmssdMs != null || s.hrr60Bpm != null || + s.hrr300Bpm != null || s.maxTorsoPitchDeg != null || s.maxTorsoRollDeg != null) { + PolarSessionSummary( + sessionId = s.id, + avgHeartRateBpm = s.avgHeartRateBpm, + maxHeartRateBpm = s.maxHeartRateBpm, + minHeartRateBpm = s.minHeartRateBpm, + avgRrIntervalMs = s.avgRrIntervalMs, + rmssdMs = s.rmssdMs, + hrr60Bpm = s.hrr60Bpm, + hrr300Bpm = s.hrr300Bpm, + maxTorsoPitchDeg = s.maxTorsoPitchDeg, + maxTorsoRollDeg = s.maxTorsoRollDeg, + ) + } else null + } ?: resolvedSessionId?.let { polarBleManager?.getSessionSummary(it) } + } + + var persistedSummary by remember(resolvedSessionId) { mutableStateOf(null) } + val summary = if (resolvedSessionId != null && latestSummary?.sessionId == resolvedSessionId) { + latestSummary + } else { + persistedSummary ?: fallbackSummary + } + + // Historical samples queries for visualizations + var beatSamples by remember(resolvedSessionId) { mutableStateOf>(emptyList()) } + var ecgSegments by remember(resolvedSessionId) { mutableStateOf>(emptyList()) } + var metricSamples by remember(resolvedSessionId) { mutableStateOf>(emptyList()) } + + LaunchedEffect(resolvedSessionId, workoutRepository) { + if (resolvedSessionId != null && workoutRepository != null) { + val savedSession = workoutRepository.getSession(resolvedSessionId) + persistedSummary = savedSession?.let { + PolarSessionSummary( + sessionId = it.id, + avgHeartRateBpm = it.avgHeartRateBpm, + maxHeartRateBpm = it.maxHeartRateBpm, + minHeartRateBpm = it.minHeartRateBpm, + avgRrIntervalMs = it.avgRrIntervalMs, + rmssdMs = it.rmssdMs, + hrr60Bpm = it.hrr60Bpm, + hrr300Bpm = it.hrr300Bpm, + maxTorsoPitchDeg = it.maxTorsoPitchDeg, + maxTorsoRollDeg = it.maxTorsoRollDeg, + ) + } + } + } + + val hasCardiacData = summary?.avgHeartRateBpm != null || summary?.maxHeartRateBpm != null || + summary?.minHeartRateBpm != null || summary?.avgRrIntervalMs != null || summary?.rmssdMs != null || + summary?.hrr60Bpm != null || summary?.hrr300Bpm != null || isCooldownActive + val hasBiomechanicsData = summary?.maxTorsoPitchDeg != null || summary?.maxTorsoRollDeg != null + + LaunchedEffect(resolvedSessionId, workoutRepository, hasCardiacData, hasBiomechanicsData, latestSummary) { + if (resolvedSessionId != null && workoutRepository != null && (hasCardiacData || hasBiomechanicsData)) { + try { + beatSamples = workoutRepository.getPolarBeatSamplesBySession(resolvedSessionId) + } catch (e: Exception) { + Logger.w { "Failed to load PolarBeatSamples: ${e.message}" } + } + try { + ecgSegments = workoutRepository.getPolarEcgSegmentsBySession(resolvedSessionId) + } catch (e: Exception) { + Logger.w { "Failed to load PolarEcgSegments: ${e.message}" } + } + try { + metricSamples = workoutRepository.getMetricSamplesBySession(resolvedSessionId) + } catch (e: Exception) { + Logger.w { "Failed to load MetricSamples: ${e.message}" } + } + } + } + + val isConnected = isCurrentSession && connectionState is ConnectionState.Connected + + // If no Polar data and not connected or in cooldown, hide the card + if (!hasCardiacData && !hasBiomechanicsData && !isConnected && !isCooldownActive) { + return + } + + Card( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + border = BorderStroke( + width = 1.dp, + color = if (isCooldownActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + }, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.medium), + verticalArrangement = Arrangement.spacedBy(Spacing.medium), + ) { + // Header Row + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.weight(1f).padding(end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background( + Brush.linearGradient( + listOf(Color(0xFFE91E63), Color(0xFFFF5252)) + ) + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Favorite, + contentDescription = "Polar H10", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp), + ) + } + Column { + Text( + text = "POLAR H10 ANALYTICS", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + letterSpacing = 1.sp, + softWrap = false, + maxLines = 1, + ) + Text( + text = if (isCooldownActive) "Recovery Cooldown Active" else "Cardiac & Biomechanics Summary", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) + } + } + + Surface( + shape = MaterialTheme.shapes.small, + color = if (isConnected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ) { + Text( + text = if (isConnected) "Connected" else "Logged", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = if (isConnected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + softWrap = false, + maxLines = 1, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } + + // Section 1: Heart Rate Stats + if (summary?.avgHeartRateBpm != null || summary?.maxHeartRateBpm != null || summary?.minHeartRateBpm != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + summary.avgHeartRateBpm?.let { avg -> + MetricTile( + label = "Average HR", + value = "$avg", + unit = "bpm", + icon = Icons.Default.Favorite, + tint = Color(0xFFE91E63), + modifier = Modifier.weight(1f), + ) + } + summary.maxHeartRateBpm?.let { max -> + MetricTile( + label = "Peak HR", + value = "$max", + unit = "bpm", + icon = Icons.Default.TrendingUp, + tint = Color(0xFFFF5722), + modifier = Modifier.weight(1f), + ) + } + summary.minHeartRateBpm?.let { min -> + MetricTile( + label = "Min HR", + value = "$min", + unit = "bpm", + icon = Icons.Default.TrendingDown, + tint = Color(0xFF2196F3), + modifier = Modifier.weight(1f), + ) + } + } + } + + // Section 2: HRV & Recovery Metrics + if (summary?.rmssdMs != null || summary?.avgRrIntervalMs != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + summary.rmssdMs?.let { rmssd -> + MetricTile( + label = "HRV (RMSSD)", + value = "${rmssd.roundToInt()}", + unit = "ms", + subtext = "Successive R-R variation", + icon = Icons.Default.Speed, + tint = Color(0xFF4CAF50), + modifier = Modifier.weight(1f), + ) + } + summary.avgRrIntervalMs?.let { rr -> + MetricTile( + label = "Avg RR Beat", + value = "${rr.roundToInt()}", + unit = "ms", + icon = Icons.Default.Timer, + tint = Color(0xFF9C27B0), + modifier = Modifier.weight(1f), + ) + } + } + } + + // Section 3: Torso Biomechanics Posture & Asymmetry + if (summary?.maxTorsoPitchDeg != null || summary?.maxTorsoRollDeg != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + summary.maxTorsoPitchDeg?.let { pitch -> + MetricTile( + label = "Max Hip Hinge", + value = "${pitch.roundToInt()}°", + unit = "pitch", + subtext = "Sagittal forward bend", + icon = Icons.Default.Accessibility, + tint = Color(0xFFFF9800), + modifier = Modifier.weight(1f), + ) + } + summary.maxTorsoRollDeg?.let { roll -> + MetricTile( + label = "Max Lateral Tilt", + value = "${roll.roundToInt()}°", + unit = "roll", + subtext = "Coronal asymmetry", + icon = Icons.Default.CompareArrows, + tint = Color(0xFF00BCD4), + modifier = Modifier.weight(1f), + ) + } + } + } + + // Section 4: 5-Minute Heart Rate Recovery (HRR) Protocol Card + val hasRecoveryData = summary?.hrr60Bpm != null || summary?.hrr300Bpm != null || isCooldownActive + if (hasRecoveryData || isConnected) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors( + containerColor = if (isCooldownActive) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f) + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + ), + border = BorderStroke( + 1.dp, + if (isCooldownActive) MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Default.Timer, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Text( + text = "5-MIN POST-WORKOUT COOLDOWN", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + + if (isCooldownActive) { + val mins = cooldownSecondsRemaining / 60 + val secs = cooldownSecondsRemaining % 60 + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.primary, + ) { + Text( + text = "${mins}:${secs.toString().padStart(2, '0')}", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + ) + } + } + } + + if (isCooldownActive) { + // Active Cooldown Display + val currentHr = hrMeasurement?.hr + val dropSoFar = if (currentHr != null && cooldownStartHr != null) { + (cooldownStartHr!! - currentHr).coerceAtLeast(0) + } else null + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = "Starting HR: ${cooldownStartHr ?: "—"} bpm", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Current HR: ${currentHr ?: "—"} bpm", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + + dropSoFar?.let { drop -> + val tint = MaterialTheme.colorScheme.primary + Surface( + shape = MaterialTheme.shapes.small, + color = tint.copy(alpha = 0.15f), + border = BorderStroke(1.dp, tint.copy(alpha = 0.4f)), + ) { + Text( + text = "-$drop bpm", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = tint, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + } + } + + // Active progress bar (300s total) + val progress = 1f - (cooldownSecondsRemaining / 300f).coerceIn(0f, 1f) + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(MaterialTheme.shapes.extraSmall), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + + // Cancel Button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + onClick = { polarBleManager?.stopCooldown() }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Icon(Icons.Default.Close, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text("Cancel Cooldown") + } + } + } else { + // Historical or Post-Cooldown State + val hrr60 = summary?.hrr60Bpm + val hrr300 = summary?.hrr300Bpm + + if (hrr60 != null || hrr300 != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + hrr60?.let { drop60 -> + RecoveryChip( + label = "1-Min Recovery (HRR-60)", + drop = drop60, + rating = "Change after 60 seconds", + modifier = Modifier.weight(1f), + ) + } + hrr300?.let { drop300 -> + RecoveryChip( + label = "5-Min Recovery (HRR-300)", + drop = drop300, + rating = "Change after 300 seconds", + modifier = Modifier.weight(1f), + ) + } + } + if (isConnected) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + onClick = { + polarBleManager?.triggerCooldown( + resolvedSessionId, + forceRestart = true, + ) + }, + ) { + Icon( + Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + "Restart Cooldown", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } else if (isConnected) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Track heart-rate change after your workout.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f).padding(end = 8.dp), + ) + FilledTonalButton( + onClick = { + polarBleManager?.triggerCooldown( + resolvedSessionId, + forceRestart = true, + ) + }, + shape = MaterialTheme.shapes.small, + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp), + modifier = Modifier.wrapContentWidth(), + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + "Start Cooldown", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + softWrap = false, + maxLines = 1, + ) + } + } + } + } + } + } + } + + // Section 5: Interactive Visualizations + if (beatSamples.isNotEmpty()) { + ContinuousHeartRateTimelineChart(samples = beatSamples) + } + + if (metricSamples.any { it.torsoPitchDeg != null || it.torsoRollDeg != null }) { + TorsoBiomechanicsVsCableChart(samples = metricSamples) + } + + if (ecgSegments.isNotEmpty()) { + SavedEcgWaveformScrubber(segments = ecgSegments) + } + } + } +} + +@Composable +private fun MetricTile( + label: String, + value: String, + unit: String, + icon: ImageVector, + tint: Color, + subtext: String? = null, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)), + ) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(14.dp), + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = value, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = unit, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 2.dp), + ) + } + if (subtext != null) { + Text( + text = subtext, + style = MaterialTheme.typography.labelSmall, + color = tint, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + } + } + } +} + +@Composable +private fun RecoveryChip( + label: String, + drop: Int, + rating: String, + modifier: Modifier = Modifier, +) { + val tint = MaterialTheme.colorScheme.primary + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.small, + color = tint.copy(alpha = 0.12f), + border = BorderStroke(1.dp, tint.copy(alpha = 0.35f)), + ) { + Column(modifier = Modifier.padding(8.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + softWrap = false, + maxLines = 1, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "-$drop bpm", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = tint, + ) + Text( + text = "($rating)", + style = MaterialTheme.typography.labelSmall, + color = tint, + softWrap = false, + maxLines = 1, + ) + } + } + } +} + +/** + * Chart 1 — Continuous HR & Recovery Timeline (`PolarBeatSample`). + * Plots BPM and RMSSD over time with color-coded phase bands and interactive scrubber. + */ +@Composable +private fun ContinuousHeartRateTimelineChart( + samples: List, + modifier: Modifier = Modifier, +) { + val validSamples = remember(samples) { + samples.filter { it.hrBpm > 0 } + } + if (validSamples.size < 2) return + + val minTs = validSamples.first().timestampMs + val maxTs = validSamples.last().timestampMs + val durationMs = (maxTs - minTs).coerceAtLeast(1000L) + + val minHr = (validSamples.minOfOrNull { it.hrBpm.toInt() } ?: 60).coerceAtMost(50) + val maxHr = ((validSamples.maxOfOrNull { it.hrBpm.toInt() } ?: 180) + 10).coerceAtLeast(minHr + 40) + val hrRange = (maxHr - minHr).toFloat() + + var scrubFraction by remember { mutableStateOf(null) } + + val selectedSample = remember(scrubFraction, validSamples) { + scrubFraction?.let { fraction -> + val targetTs = minTs + (fraction * durationMs).toLong() + validSamples.minByOrNull { abs(it.timestampMs - targetTs) } + } + } + + Card( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.weight(1f).padding(end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Default.ShowChart, + contentDescription = null, + tint = Color(0xFFFF5252), + modifier = Modifier.size(16.dp), + ) + Text( + text = "CONTINUOUS HR & RECOVERY TIMELINE", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + letterSpacing = 0.5.sp, + ) + } + + Text( + text = "${(durationMs / 60000)}m ${(durationMs % 60000) / 1000}s", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + softWrap = false, + maxLines = 1, + ) + } + + // Legend Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PhaseLegendDot(color = Color(0xFFFFA726), label = "Warmup") + PhaseLegendDot(color = Color(0xFFEF5350), label = "Working") + PhaseLegendDot(color = Color(0xFF42A5F5), label = "Rest") + PhaseLegendDot(color = Color(0xFF26A69A), label = "Cooldown") + Spacer(Modifier.weight(1f)) + PhaseLegendDot(color = Color(0xFF00E676), label = "HRV", isLine = true) + } + + // Scrub Info Bar + if (selectedSample != null) { + val elapsedSec = ((selectedSample.timestampMs - minTs) / 1000).coerceAtLeast(0) + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${elapsedSec / 60}m ${elapsedSec % 60}s [${selectedSample.phase}]", + style = MaterialTheme.typography.labelSmall, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + softWrap = false, + maxLines = 1, + modifier = Modifier.weight(1f, fill = false).padding(end = 6.dp), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "HR: ${selectedSample.hrBpm} bpm", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color(0xFFFF5252), + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + softWrap = false, + maxLines = 1, + ) + selectedSample.rmssdMs?.let { rmssd -> + Text( + text = "RMSSD: ${rmssd.roundToInt()} ms", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color(0xFF00E676), + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + softWrap = false, + maxLines = 1, + ) + } + } + } + } + } + + // Canvas Line Chart + Box( + modifier = Modifier + .fillMaxWidth() + .height(150.dp) + .clip(MaterialTheme.shapes.small) + .background(Color(0xFF0F172A)) + .pointerInput(Unit) { + detectTapGestures( + onPress = { offset -> + scrubFraction = (offset.x / size.width).coerceIn(0f, 1f) + tryAwaitRelease() + scrubFraction = null + } + ) + } + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { offset -> + scrubFraction = (offset.x / size.width).coerceIn(0f, 1f) + }, + onDragEnd = { scrubFraction = null }, + onDragCancel = { scrubFraction = null }, + onHorizontalDrag = { change, _ -> + scrubFraction = (change.position.x / size.width).coerceIn(0f, 1f) + } + ) + }, + ) { + val scrubberLineColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f) + val scrubberDotColor = MaterialTheme.colorScheme.surface + Canvas(modifier = Modifier.fillMaxSize()) { + val w = size.width + val h = size.height + + // 1. Draw Phase Bands + var currentPhase = validSamples.first().phase + var spanStartTs = validSamples.first().timestampMs + val spans = mutableListOf>() + for (s in validSamples) { + if (s.phase != currentPhase) { + spans.add(Triple(currentPhase, spanStartTs, s.timestampMs)) + currentPhase = s.phase + spanStartTs = s.timestampMs + } + } + spans.add(Triple(currentPhase, spanStartTs, maxTs)) + + for (span in spans) { + val x0 = ((span.second - minTs).toFloat() / durationMs) * w + val x1 = ((span.third - minTs).toFloat() / durationMs) * w + val bandColor = when (span.first) { + "WARMUP" -> Color(0xFFFFA726).copy(alpha = 0.14f) + "WORKING_SET" -> Color(0xFFEF5350).copy(alpha = 0.20f) + "REST" -> Color(0xFF42A5F5).copy(alpha = 0.14f) + "POST_WORKOUT_COOLDOWN" -> Color(0xFF26A69A).copy(alpha = 0.22f) + else -> Color.Transparent + } + drawRect(color = bandColor, topLeft = Offset(x0, 0f), size = Size((x1 - x0).coerceAtLeast(1f), h)) + } + + // 2. Draw Horizontal Grid Baseline Lines + listOf(80, 120, 160).forEach { bpm -> + if (bpm in minHr..maxHr) { + val y = h - ((bpm - minHr) / hrRange) * h + drawLine( + color = Color(0xFF334155).copy(alpha = 0.6f), + start = Offset(0f, y), + end = Offset(w, y), + strokeWidth = 1f, + ) + } + } + + // 3. RMSSD Curve (Secondary) + val rmssdPath = Path() + var rmssdStarted = false + val maxRmssd = 120f + for (s in validSamples) { + if (s.rmssdMs != null) { + val x = ((s.timestampMs - minTs).toFloat() / durationMs) * w + val y = h - (s.rmssdMs.toFloat().coerceIn(0f, maxRmssd) / maxRmssd) * (h * 0.7f) + if (!rmssdStarted) { + rmssdPath.moveTo(x, y) + rmssdStarted = true + } else { + rmssdPath.lineTo(x, y) + } + } + } + if (rmssdStarted) { + drawPath( + path = rmssdPath, + color = Color(0xFF00E676).copy(alpha = 0.5f), + style = Stroke(width = 1.2.dp.toPx()), + ) + } + + // 4. Heart Rate Curve (Primary) + val hrPath = Path() + val hrFillPath = Path() + var hrStarted = false + for (s in validSamples) { + val hr = s.hrBpm.toInt() + val x = ((s.timestampMs - minTs).toFloat() / durationMs) * w + val y = h - ((hr - minHr) / hrRange) * h + if (!hrStarted) { + hrPath.moveTo(x, y) + hrFillPath.moveTo(x, h) + hrFillPath.lineTo(x, y) + hrStarted = true + } else { + hrPath.lineTo(x, y) + hrFillPath.lineTo(x, y) + } + } + if (hrStarted) { + hrFillPath.lineTo(w, h) + hrFillPath.close() + + drawPath( + path = hrFillPath, + brush = Brush.verticalGradient( + colors = listOf(Color(0xFFFF5252).copy(alpha = 0.25f), Color.Transparent), + startY = 0f, + endY = h, + ), + ) + + drawPath( + path = hrPath, + color = Color(0xFFFF5252), + style = Stroke( + width = 2.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + } + + // 5. Scrubber Indicator + scrubFraction?.let { fraction -> + val scrubX = fraction * w + drawLine( + color = scrubberLineColor, + start = Offset(scrubX, 0f), + end = Offset(scrubX, h), + strokeWidth = 1.5.dp.toPx(), + ) + selectedSample?.hrBpm?.let { hr -> + val dotY = h - ((hr.toInt() - minHr) / hrRange) * h + drawCircle(color = scrubberDotColor, radius = 4.dp.toPx(), center = Offset(scrubX, dotY)) + drawCircle(color = Color(0xFFFF5252), radius = 2.5.dp.toPx(), center = Offset(scrubX, dotY)) + } + } + } + } + } + } +} + +/** + * Chart 2 — 3D Torso Biomechanics vs. Rep Position (`PolarMetricSample`). + * Plots intra-set torso pitch (hinge) and roll (tilt) synchronized with cable position/force. + */ +@Composable +private fun TorsoBiomechanicsVsCableChart( + samples: List, + modifier: Modifier = Modifier, +) { + val biomechSamples = remember(samples) { + samples.filter { it.torsoPitchDeg != null || it.torsoRollDeg != null } + } + if (biomechSamples.isEmpty()) return + + val sampleCount = biomechSamples.size + var scrubFraction by remember { mutableStateOf(null) } + + val selectedSample = remember(scrubFraction, biomechSamples) { + scrubFraction?.let { fraction -> + val index = (fraction * (sampleCount - 1)).roundToInt().coerceIn(0, sampleCount - 1) + biomechSamples[index] + } + } + + // Dynamic Left Axis (Degrees): + // Collect all angles present, ensuring 0° is always included with padding + val pitchAngles = remember(biomechSamples) { biomechSamples.mapNotNull { it.torsoPitchDeg?.toFloat() } } + val rollAngles = remember(biomechSamples) { biomechSamples.mapNotNull { it.torsoRollDeg?.toFloat() } } + val allAngles = remember(pitchAngles, rollAngles) { pitchAngles + rollAngles } + val rawMinAngle = (allAngles.minOrNull() ?: 0f).coerceAtMost(0f) + val rawMaxAngle = (allAngles.maxOrNull() ?: 45f).coerceAtLeast(0f) + val minAngle = rawMinAngle - 5f + val maxAngle = (rawMaxAngle + 5f).coerceAtLeast(minAngle + 10f) + val angleRange = (maxAngle - minAngle).coerceAtLeast(1f) + + // Dynamic Right Axis (Cable Position): + // Independent normalization to session's min/max active cable position (position + positionB) / 2f + val cablePositions = remember(biomechSamples) { + biomechSamples.mapNotNull { s -> + if (s.position != null && s.positionB != null) { + (s.position.toFloat() + s.positionB.toFloat()) / 2f + } else { + s.position?.toFloat() ?: s.positionB?.toFloat() + } + } + } + val rawMinPos = cablePositions.minOrNull() ?: 0f + val rawMaxPos = cablePositions.maxOrNull() ?: 1f + val posSpan = (rawMaxPos - rawMinPos).coerceAtLeast(0.05f) + val minPos = (rawMinPos - posSpan * 0.05f).coerceAtLeast(0f) + val maxPos = rawMaxPos + posSpan * 0.05f + val posRange = (maxPos - minPos).coerceAtLeast(0.01f) + + Card( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.weight(1f).padding(end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Default.Accessibility, + contentDescription = null, + tint = Color(0xFFFF9800), + modifier = Modifier.size(16.dp), + ) + Text( + text = "3D TORSO BIOMECHANICS & CABLE POSITION", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + letterSpacing = 0.5.sp, + ) + } + + Text( + text = "$sampleCount reps/pts", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + softWrap = false, + maxLines = 1, + ) + } + + // Legend & Dynamic Axis Info Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PhaseLegendDot(color = Color(0xFFFF9800), label = "Hinge (Pitch°)") + PhaseLegendDot(color = Color(0xFF00BCD4), label = "Tilt (Roll°)") + PhaseLegendDot(color = Color(0xFFAB47BC), label = "Cable Pos", isLine = true) + Spacer(Modifier.weight(1f)) + Text( + text = "0° ref | ${minAngle.roundToInt()}°..${maxAngle.roundToInt()}°", + style = MaterialTheme.typography.labelSmall, + fontSize = 9.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + softWrap = false, + maxLines = 1, + ) + } + + // Scrub Info Bar + if (selectedSample != null) { + val cablePos = if (selectedSample.position != null && selectedSample.positionB != null) { + (selectedSample.position.toFloat() + selectedSample.positionB.toFloat()) / 2f + } else { + selectedSample.position?.toFloat() ?: selectedSample.positionB?.toFloat() + } + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Hinge: ${selectedSample.torsoPitchDeg?.let { "${(it * 10).roundToInt() / 10.0}°" } ?: "—"}", + style = MaterialTheme.typography.labelSmall, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = Color(0xFFFF9800), + ) + Text( + text = "Tilt: ${selectedSample.torsoRollDeg?.let { "${(it * 10).roundToInt() / 10.0}°" } ?: "—"}", + style = MaterialTheme.typography.labelSmall, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = Color(0xFF00BCD4), + ) + cablePos?.let { pos -> + val romPercent = ((pos - minPos) / posRange * 100f).roundToInt().coerceIn(0, 100) + Text( + text = "Cable: ${romPercent}%", + style = MaterialTheme.typography.labelSmall, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = Color(0xFFAB47BC), + softWrap = false, + maxLines = 1, + ) + } + } + } + } + + // Canvas Plot + Box( + modifier = Modifier + .fillMaxWidth() + .height(150.dp) + .clip(MaterialTheme.shapes.small) + .background(Color(0xFF0D1B2A)) + .pointerInput(Unit) { + detectTapGestures( + onPress = { offset -> + scrubFraction = (offset.x / size.width).coerceIn(0f, 1f) + tryAwaitRelease() + scrubFraction = null + } + ) + } + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { offset -> + scrubFraction = (offset.x / size.width).coerceIn(0f, 1f) + }, + onDragEnd = { scrubFraction = null }, + onDragCancel = { scrubFraction = null }, + onHorizontalDrag = { change, _ -> + scrubFraction = (change.position.x / size.width).coerceIn(0f, 1f) + } + ) + }, + ) { + val scrubberLineColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f) + val scrubberDotColor = MaterialTheme.colorScheme.surface + Canvas(modifier = Modifier.fillMaxSize()) { + val w = size.width + val h = size.height + + val padTop = 14f + val padBottom = 14f + val plotH = h - padTop - padBottom + + // Center 0° baseline for posture reference + val zeroY = padTop + (1f - (0f - minAngle) / angleRange) * plotH + drawLine( + color = Color(0xFF1E3A5F), + start = Offset(0f, zeroY), + end = Offset(w, zeroY), + strokeWidth = 1.2f, + ) + + // Degree tick marker on left edge + drawLine( + color = Color(0xFF4A7DA5), + start = Offset(0f, zeroY), + end = Offset(10.dp.toPx(), zeroY), + strokeWidth = 2f, + ) + + val stepX = w / (sampleCount - 1).coerceAtLeast(1).toFloat() + + // 1. Cable Position (Purple line scaled independently across full height) + val cablePath = Path() + var cableStarted = false + biomechSamples.forEachIndexed { i, s -> + val pos = if (s.position != null && s.positionB != null) { + (s.position.toFloat() + s.positionB.toFloat()) / 2f + } else { + s.position?.toFloat() ?: s.positionB?.toFloat() + } + if (pos != null) { + val x = i * stepX + val y = padTop + (1f - (pos - minPos) / posRange) * plotH + if (!cableStarted) { + cablePath.moveTo(x, y) + cableStarted = true + } else { + cablePath.lineTo(x, y) + } + } + } + if (cableStarted) { + drawPath( + path = cablePath, + color = Color(0xFFAB47BC).copy(alpha = 0.75f), + style = Stroke( + width = 1.8.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + } + + // 2. Torso Pitch (Hip Hinge, orange line scaled across angle range) + val pitchPath = Path() + var pitchStarted = false + biomechSamples.forEachIndexed { i, s -> + s.torsoPitchDeg?.let { pitch -> + val x = i * stepX + val y = padTop + (1f - (pitch.toFloat() - minAngle) / angleRange) * plotH + if (!pitchStarted) { + pitchPath.moveTo(x, y) + pitchStarted = true + } else { + pitchPath.lineTo(x, y) + } + } + } + if (pitchStarted) { + drawPath( + path = pitchPath, + color = Color(0xFFFF9800), + style = Stroke( + width = 2.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + } + + // 3. Torso Roll (Lateral Tilt, cyan line scaled across angle range) + val rollPath = Path() + var rollStarted = false + biomechSamples.forEachIndexed { i, s -> + s.torsoRollDeg?.let { roll -> + val x = i * stepX + val y = padTop + (1f - (roll.toFloat() - minAngle) / angleRange) * plotH + if (!rollStarted) { + rollPath.moveTo(x, y) + rollStarted = true + } else { + rollPath.lineTo(x, y) + } + } + } + if (rollStarted) { + drawPath( + path = rollPath, + color = Color(0xFF00BCD4), + style = Stroke( + width = 1.5.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + } + + // 4. Scrubber Line & Indicators + scrubFraction?.let { fraction -> + val scrubX = fraction * w + drawLine( + color = scrubberLineColor, + start = Offset(scrubX, 0f), + end = Offset(scrubX, h), + strokeWidth = 1.5.dp.toPx(), + ) + selectedSample?.torsoPitchDeg?.let { pitch -> + val pitchY = padTop + (1f - (pitch.toFloat() - minAngle) / angleRange) * plotH + drawCircle(color = scrubberDotColor, radius = 4.dp.toPx(), center = Offset(scrubX, pitchY)) + drawCircle(color = Color(0xFFFF9800), radius = 2.5.dp.toPx(), center = Offset(scrubX, pitchY)) + } + val selCablePos = if (selectedSample?.position != null && selectedSample?.positionB != null) { + (selectedSample.position!!.toFloat() + selectedSample.positionB!!.toFloat()) / 2f + } else { + selectedSample?.position?.toFloat() ?: selectedSample?.positionB?.toFloat() + } + selCablePos?.let { pos -> + val cableY = padTop + (1f - (pos - minPos) / posRange) * plotH + drawCircle(color = scrubberDotColor, radius = 4.dp.toPx(), center = Offset(scrubX, cableY)) + drawCircle(color = Color(0xFFAB47BC), radius = 2.5.dp.toPx(), center = Offset(scrubX, cableY)) + } + } + } + } + } + } +} + +/** + * Chart 3 — Saved 130 Hz ECG Waveform Scrubber (`PolarEcgSegment`). + * Renders parsed microvolt samples across clinical ECG grid with interactive window scrubber. + */ +@Composable +private fun SavedEcgWaveformScrubber( + segments: List, + modifier: Modifier = Modifier, +) { + if (segments.isEmpty()) return + + val segmentCounts = remember(segments) { + segments.map { seg -> if (seg.samplesUvCsv.isEmpty()) 0 else seg.samplesUvCsv.count { it == ',' } + 1 } + } + val totalSamples = remember(segmentCounts) { segmentCounts.sum() } + if (totalSamples < 10) return + + val windowSize = 260 // 2.0 seconds at 130 Hz + var windowStart by remember { mutableStateOf(0) } + val maxStart = (totalSamples - windowSize).coerceAtLeast(0) + + val currentPhase = remember(windowStart, segments, segmentCounts) { + var accum = 0 + var phase = segments.first().phase + for (i in segments.indices) { + val count = segmentCounts.getOrElse(i) { 0 } + if (windowStart in accum until (accum + count)) { + phase = segments[i].phase + break + } + accum += count + } + phase + } + + Card( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.weight(1f).padding(end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Default.Favorite, + contentDescription = null, + tint = Color(0xFF00E5FF), + modifier = Modifier.size(16.dp), + ) + Text( + text = "SAVED 130 HZ ECG WAVEFORM SCRUBBER", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + letterSpacing = 0.5.sp, + ) + } + + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f), + ) { + Text( + text = currentPhase.replace("_", " "), + style = MaterialTheme.typography.labelSmall, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + softWrap = false, + maxLines = 1, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + + // Window details and slider + val startSec = (windowStart / 130.0 * 10).roundToInt() / 10.0 + val endSec = (((windowStart + windowSize).coerceAtMost(totalSamples)) / 130.0 * 10).roundToInt() / 10.0 + val totalSec = (totalSamples / 130.0 * 10).roundToInt() / 10.0 + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Time: ${startSec}s - ${endSec}s / ${totalSec}s", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + modifier = Modifier.weight(1f).padding(end = 8.dp), + ) + Text( + text = "$totalSamples samples @ 130 Hz", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + softWrap = false, + maxLines = 1, + ) + } + + if (maxStart > 0) { + Slider( + value = windowStart.toFloat(), + onValueChange = { windowStart = it.roundToInt().coerceIn(0, maxStart) }, + valueRange = 0f..maxStart.toFloat(), + modifier = Modifier + .fillMaxWidth() + .height(24.dp), + ) + } + + // ECG Grid & Trace Canvas + Box( + modifier = Modifier + .fillMaxWidth() + .height(140.dp) + .clip(MaterialTheme.shapes.small) + .background(Color(0xFF081422)) + .border(0.5.dp, Color(0xFF1B3A5A), MaterialTheme.shapes.small), + ) { + val windowSlice by produceState>( + initialValue = emptyList(), + key1 = segments, + key2 = windowStart, + ) { + value = withContext(Dispatchers.Default) { + val result = mutableListOf() + var currentOffset = 0 + val targetEnd = (windowStart + windowSize).coerceAtMost(totalSamples) + for (i in segments.indices) { + val seg = segments[i] + val count = segmentCounts.getOrElse(i) { 0 } + val segStart = currentOffset + val segEnd = currentOffset + count + if (segEnd > windowStart && segStart < targetEnd) { + val segSamples = seg.samplesUvCsv.split(',').mapNotNull { it.trim().toIntOrNull() } + val startInSeg = (windowStart - segStart).coerceAtLeast(0) + val endInSeg = (targetEnd - segStart).coerceAtMost(segSamples.size) + if (startInSeg < endInSeg && startInSeg < segSamples.size) { + result.addAll(segSamples.subList(startInSeg, endInSeg)) + } + } + currentOffset += count + if (currentOffset >= targetEnd) break + } + result + } + } + + Canvas(modifier = Modifier.fillMaxSize()) { + val w = size.width + val h = size.height + val midY = h / 2f + + // 1. Draw Clinical ECG Grid + val minorStep = 10.dp.toPx() + var gx = 0f + while (gx < w) { + drawLine(color = Color(0xFF0E2238), start = Offset(gx, 0f), end = Offset(gx, h), strokeWidth = 0.5f) + gx += minorStep + } + var gy = 0f + while (gy < h) { + drawLine(color = Color(0xFF0E2238), start = Offset(0f, gy), end = Offset(w, gy), strokeWidth = 0.5f) + gy += minorStep + } + val majorStep = 40.dp.toPx() + gx = 0f + while (gx < w) { + drawLine(color = Color(0xFF1B3A5A), start = Offset(gx, 0f), end = Offset(gx, h), strokeWidth = 1f) + gx += majorStep + } + gy = 0f + while (gy < h) { + drawLine(color = Color(0xFF1B3A5A), start = Offset(0f, gy), end = Offset(w, gy), strokeWidth = 1f) + gy += majorStep + } + + // Zero baseline center line + drawLine(color = Color(0xFF2A5580), start = Offset(0f, midY), end = Offset(w, midY), strokeWidth = 1.5f) + + if (windowSlice.size < 2) return@Canvas + + // 2. Autoscale µV + val maxAbsUv = windowSlice.maxOfOrNull { abs(it) }?.toFloat()?.coerceAtLeast(800f) ?: 1500f + val scaleY = (h * 0.42f) / maxAbsUv + val stepX = w / (windowSlice.size - 1).toFloat() + + // 3. Draw ECG Trace + val path = Path() + windowSlice.forEachIndexed { i, uv -> + val x = i * stepX + val y = (midY - (uv * scaleY)).coerceIn(1f, h - 1f) + if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) + } + + drawPath( + path = path, + color = Color(0xFF00E5FF), + style = Stroke( + width = 1.8.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + } + } + } + } +} + +@Composable +private fun PhaseLegendDot( + color: Color, + label: String, + isLine: Boolean = false, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (isLine) { + Box( + modifier = Modifier + .width(10.dp) + .height(2.dp) + .background(color), + ) + } else { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(color), + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontSize = 9.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/ActiveWorkoutScreen.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/ActiveWorkoutScreen.kt index 46f3b0258..5ee19e027 100644 --- a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/ActiveWorkoutScreen.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/ActiveWorkoutScreen.kt @@ -35,10 +35,12 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.navigation.NavController import co.touchlab.kermit.Logger +import com.devil.phoenixproject.data.ble.PolarH10BleManager import com.devil.phoenixproject.data.repository.ExerciseRepository import com.devil.phoenixproject.data.repository.GamificationRepository import com.devil.phoenixproject.data.repository.UserProfileRepository import com.devil.phoenixproject.domain.model.Badge +import com.devil.phoenixproject.domain.model.ConnectionState import com.devil.phoenixproject.domain.model.PRCelebrationEvent import com.devil.phoenixproject.domain.model.RoutineFlowState import com.devil.phoenixproject.domain.model.WorkoutState @@ -48,6 +50,7 @@ import com.devil.phoenixproject.presentation.components.BackHandler import com.devil.phoenixproject.presentation.components.BatchedBadgeCelebrationDialog import com.devil.phoenixproject.presentation.components.ConnectionErrorDialog import com.devil.phoenixproject.presentation.components.PRCelebrationDialog +import com.devil.phoenixproject.presentation.components.PolarH10WorkoutStrip import com.devil.phoenixproject.presentation.manager.DefaultWorkoutSessionManager import com.devil.phoenixproject.presentation.navigation.NavigationRoutes import com.devil.phoenixproject.presentation.navigation.safePopOrNavigate @@ -86,7 +89,12 @@ import projectphoenix.shared.generated.resources.voice_stop_unavailable_snackbar */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ActiveWorkoutScreen(navController: NavController, viewModel: MainViewModel, exerciseRepository: ExerciseRepository) { +fun ActiveWorkoutScreen( + navController: NavController, + viewModel: MainViewModel, + exerciseRepository: ExerciseRepository, + polarBleManager: PolarH10BleManager = koinInject(), +) { val workoutState by viewModel.workoutState.collectAsState() val currentMetric by viewModel.currentMetric.collectAsState() val currentHeuristicKgMax by viewModel.currentHeuristicKgMax.collectAsState() @@ -120,6 +128,8 @@ fun ActiveWorkoutScreen(navController: NavController, viewModel: MainViewModel, val motionStartHoldProgress by viewModel.motionStartHoldProgress.collectAsState() // Issue #297, #228: Rest timer pause state val isRestPaused by viewModel.isRestPaused.collectAsState() + val isPolarCooldownActive by polarBleManager.isCooldownActive.collectAsState() + val activePolarCooldownSessionId by polarBleManager.activeCooldownSessionId.collectAsState() // Phase 35C: Variable warm-up set state val currentWarmupSetIndex by viewModel.currentWarmupSetIndex.collectAsState() // Issue #113: Just Lift visual rest countdown @@ -284,7 +294,7 @@ fun ActiveWorkoutScreen(navController: NavController, viewModel: MainViewModel, // Watch for workout completion and navigate back // For Just Lift, navigate back when state becomes Idle (after auto-reset) // Key only on workoutState to avoid re-triggering on workoutParameters changes - LaunchedEffect(workoutState) { + LaunchedEffect(workoutState, isPolarCooldownActive, activePolarCooldownSessionId) { // Guard against double navigation if (hasNavigatedAway) return@LaunchedEffect @@ -293,10 +303,15 @@ fun ActiveWorkoutScreen(navController: NavController, viewModel: MainViewModel, } when { workoutState is WorkoutState.Completed -> { - Logger.d { "ActiveWorkoutScreen: Workout completed, navigating back in 2s" } - delay(2000) - hasNavigatedAway = true - navController.navigateUp() + val isCooldownRunning = isPolarCooldownActive || activePolarCooldownSessionId != null + if (!isCooldownRunning) { + Logger.d { "ActiveWorkoutScreen: Workout completed (no Polar cooldown active), navigating back in 2s" } + delay(2000) + hasNavigatedAway = true + navController.navigateUp() + } else { + Logger.d { "ActiveWorkoutScreen: Workout completed with Polar cooldown active; remaining on summary" } + } } workoutState is WorkoutState.Idle && workoutParameters.isJustLift -> { @@ -528,6 +543,10 @@ fun ActiveWorkoutScreen(navController: NavController, viewModel: MainViewModel, .padding(horizontal = 16.dp, vertical = 4.dp), ) } + + // Polar H10 Live Telemetry Strip (visible when connected) + PolarH10WorkoutStrip(polarBleManager = viewModel.polarBleManager) + WorkoutTab( state = workoutUiState, actions = workoutActions, @@ -538,6 +557,8 @@ fun ActiveWorkoutScreen(navController: NavController, viewModel: MainViewModel, personalRecordRepository = viewModel.personalRecordRepository, userPreferences = userPreferences, customEquipmentOverlay = customEquipmentOverlay, + polarBleManager = viewModel.polarBleManager, + workoutRepository = viewModel.workoutRepository, ) } } diff --git a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt index 839d77fd3..93cafb84b 100644 --- a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt @@ -572,6 +572,8 @@ fun AnalyticsScreen( }, recentJustLiftExerciseIds = recentJustLiftExerciseIds, customEquipmentOverlay = customEquipmentOverlay, + polarBleManager = viewModel.polarBleManager, + workoutRepository = viewModel.workoutRepository, modifier = Modifier.fillMaxSize(), ) } diff --git a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/ConnectionLogsScreen.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/ConnectionLogsScreen.kt index ea278fc11..c5bc4b121 100644 --- a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/ConnectionLogsScreen.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/ConnectionLogsScreen.kt @@ -209,6 +209,11 @@ fun ConnectionLogsScreen( count = logs.count { it.level == LogLevel.ERROR.name }, onClick = { logsViewModel.toggleLevel(LogLevel.ERROR) }, ) + FilterChip( + selected = filter.hrmOnly, + onClick = { logsViewModel.toggleHrmOnly() }, + label = { Text("HRM") }, + ) } } else { Row( @@ -242,6 +247,11 @@ fun ConnectionLogsScreen( count = logs.count { it.level == LogLevel.ERROR.name }, onClick = { logsViewModel.toggleLevel(LogLevel.ERROR) }, ) + FilterChip( + selected = filter.hrmOnly, + onClick = { logsViewModel.toggleHrmOnly() }, + label = { Text("HRM") }, + ) } } @@ -550,6 +560,20 @@ private fun LogEntryCard(log: ConnectionLogEntity) { fontWeight = FontWeight.Bold, color = levelColor, ) + if (log.tag != null) { + Spacer(modifier = Modifier.width(6.dp)) + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = MaterialTheme.shapes.extraSmall, + ) { + Text( + text = log.tag, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } } // Timestamp diff --git a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/HistoryTab.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/HistoryTab.kt index 70c0e1ee1..137bdfa8e 100644 --- a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/HistoryTab.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/HistoryTab.kt @@ -52,10 +52,12 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.shadow import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.devil.phoenixproject.data.ble.PolarH10BleManager import com.devil.phoenixproject.data.repository.BiomechanicsRepository import com.devil.phoenixproject.data.repository.CompletedSetRepository import com.devil.phoenixproject.data.repository.ExerciseRepository import com.devil.phoenixproject.data.repository.RepMetricRepository +import com.devil.phoenixproject.data.repository.WorkoutRepository import com.devil.phoenixproject.domain.model.BiomechanicsRepResult import com.devil.phoenixproject.domain.model.CompletedSet import com.devil.phoenixproject.domain.model.Exercise @@ -75,6 +77,7 @@ import com.devil.phoenixproject.presentation.components.DestructiveConfirmDialog import com.devil.phoenixproject.presentation.components.EmptyState import com.devil.phoenixproject.presentation.components.MiniExercisePickerDialog import com.devil.phoenixproject.presentation.components.WorkoutHistoryCardSkeleton +import com.devil.phoenixproject.presentation.components.PolarSessionAnalyticsCard import com.devil.phoenixproject.presentation.components.RepBiomechanicsDetail import com.devil.phoenixproject.presentation.components.RepReplayCard import com.devil.phoenixproject.presentation.components.charts.HistoryTimePeriod @@ -132,6 +135,8 @@ fun HistoryTab( recentJustLiftExerciseIds: List = emptyList(), // Issue #1227: TOKEN -> LABEL custom-equipment overlay for the picker dialogs. customEquipmentOverlay: Map = emptyMap(), + polarBleManager: PolarH10BleManager? = null, + workoutRepository: WorkoutRepository? = null, modifier: Modifier = Modifier, ) { // M8: Hoist koinInject calls to parent composable scope (outside LazyColumn items). @@ -228,6 +233,8 @@ fun HistoryTab( onClearJustLiftSessionExercise = onClearJustLiftSessionExercise, recentJustLiftExerciseIds = recentJustLiftExerciseIds, customEquipmentOverlay = customEquipmentOverlay, + polarBleManager = polarBleManager, + workoutRepository = workoutRepository, onDelete = { onDeleteWorkout(item.session.id) }, ) } @@ -244,6 +251,8 @@ fun HistoryTab( onClearJustLiftSessionExercise = onClearJustLiftSessionExercise, recentJustLiftExerciseIds = recentJustLiftExerciseIds, customEquipmentOverlay = customEquipmentOverlay, + polarBleManager = polarBleManager, + workoutRepository = workoutRepository, // Issue #591 follow-up: thread the // routine-level delete callback so the // History "Delete All Sets" path also @@ -274,6 +283,8 @@ private fun WorkoutHistoryCard( recentJustLiftExerciseIds: List = emptyList(), // Issue #1227: TOKEN -> LABEL custom-equipment overlay for the tag picker. customEquipmentOverlay: Map = emptyMap(), + polarBleManager: PolarH10BleManager? = null, + workoutRepository: WorkoutRepository? = null, onDelete: () -> Unit, ) { val scope = rememberCoroutineScope() @@ -496,6 +507,7 @@ private fun WorkoutHistoryCard( summaryCountdownSeconds = 0, // History view - no auto-continue isHistoryView = true, savedRpe = session.rpe, + polarBleManager = polarBleManager, // #972: the tag section doubles as the label editor on History — // it shows the stored name with Change/Clear once a segment is // labeled, and the Tag prompt when it is not. @@ -561,6 +573,15 @@ private fun WorkoutHistoryCard( // Biomechanics Section (v0.5.0+, tier-gated) BiomechanicsSection(session = session) + + // Polar H10 Cardiac & Biomechanics Telemetry Section + PolarSessionAnalyticsCard( + sessionId = session.id, + session = session, + polarBleManager = polarBleManager, + workoutRepository = workoutRepository, + showCooldown = false, + ) } } @@ -826,6 +847,8 @@ private fun GroupedRoutineCard( recentJustLiftExerciseIds: List = emptyList(), // Issue #1227: TOKEN -> LABEL custom-equipment overlay for the tag picker. customEquipmentOverlay: Map = emptyMap(), + polarBleManager: PolarH10BleManager? = null, + workoutRepository: WorkoutRepository? = null, // Issue #591 follow-up: receives routineSessionId so the caller can // soft-delete every WorkoutSession row for the routine (including // zero-rep ghost rows hidden by `getHistoryVisibleSessions`). @@ -1110,6 +1133,7 @@ private fun GroupedRoutineCard( summaryCountdownSeconds = 0, // History view - no auto-continue isHistoryView = true, savedRpe = session.rpe, + polarBleManager = polarBleManager, // #972: label editing, same treatment as the single-session card. isJustLiftTaggingEnabled = canEditJustLiftLabel, taggedExerciseName = session.exerciseName, @@ -1176,6 +1200,38 @@ private fun GroupedRoutineCard( Spacer(modifier = Modifier.height(Spacing.medium)) } } + + // Polar H10 Cardiac & Biomechanics Telemetry Section (rendered below all exercises & rep details) + val polarSession = groupedItem.sessions + .filter { + it.avgHeartRateBpm != null || it.maxHeartRateBpm != null || + it.minHeartRateBpm != null || it.avgRrIntervalMs != null || + it.rmssdMs != null || it.hrr60Bpm != null || it.hrr300Bpm != null || + it.maxTorsoPitchDeg != null || it.maxTorsoRollDeg != null + } + .sortedWith(compareBy { + it.hrr60Bpm != null || it.hrr300Bpm != null + }.thenBy { it.timestamp }) + .lastOrNull() + + if (polarSession != null) { + Spacer(modifier = Modifier.height(Spacing.medium)) + Text( + text = if (polarSession.hrr60Bpm != null || polarSession.hrr300Bpm != null) { + "Latest set with recovery data: ${polarSession.exerciseName}" + } else { + "Most recent set with sensor data: ${polarSession.exerciseName}" + }, + style = MaterialTheme.typography.labelMedium, + ) + PolarSessionAnalyticsCard( + sessionId = polarSession.id, + session = polarSession, + polarBleManager = polarBleManager, + workoutRepository = workoutRepository, + showCooldown = false, + ) + } } } diff --git a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineCompleteScreen.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineCompleteScreen.kt index 2da944d17..d35419372 100644 --- a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineCompleteScreen.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineCompleteScreen.kt @@ -24,8 +24,10 @@ import com.devil.phoenixproject.ui.theme.Spacing import com.devil.phoenixproject.ui.theme.celebrationBackgroundBrush import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import com.devil.phoenixproject.data.ble.PolarH10BleManager import com.devil.phoenixproject.domain.model.RoutineFlowState import com.devil.phoenixproject.presentation.components.BackHandler +import com.devil.phoenixproject.presentation.components.PolarSessionAnalyticsCard import com.devil.phoenixproject.presentation.navigation.NavigationRoutes import com.devil.phoenixproject.presentation.navigation.safePopOrNavigate import com.devil.phoenixproject.presentation.viewmodel.MainViewModel @@ -46,7 +48,11 @@ import projectphoenix.shared.generated.resources.Res * only guaranteed exit. */ @Composable -fun RoutineCompleteScreen(navController: NavController, viewModel: MainViewModel) { +fun RoutineCompleteScreen( + navController: NavController, + viewModel: MainViewModel, + polarBleManager: PolarH10BleManager? = viewModel.polarBleManager, +) { val routineFlowState by viewModel.routineFlowState.collectAsState() val completeState = routineFlowState as? RoutineFlowState.Complete @@ -253,6 +259,20 @@ fun RoutineCompleteScreen(navController: NavController, viewModel: MainViewModel } } } + + // Polar H10 Session Analytics & 5-minute recovery cooldown + val lastRecordedSessionId = polarBleManager?.lastRecordedSessionId?.collectAsState()?.value + val workoutHistory by viewModel.workoutHistory.collectAsState() + val latestCompletedSession = remember(lastRecordedSessionId, workoutHistory) { + lastRecordedSessionId?.let { id -> workoutHistory.firstOrNull { it.id == id } } + } + PolarSessionAnalyticsCard( + sessionId = lastRecordedSessionId, + session = latestCompletedSession, + modifier = Modifier.fillMaxWidth(), + polarBleManager = polarBleManager, + workoutRepository = viewModel.workoutRepository, + ) } // Reserved Done footer — sibling of the scroll body, outside every diff --git a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/SetSummaryCard.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/SetSummaryCard.kt index ce9a09746..9abd7768f 100644 --- a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/SetSummaryCard.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/SetSummaryCard.kt @@ -44,6 +44,9 @@ import com.devil.phoenixproject.ui.theme.AccessibilityTheme import com.devil.phoenixproject.ui.theme.qualityScoreColor import com.devil.phoenixproject.ui.theme.velocityZoneColor import com.devil.phoenixproject.ui.theme.velocityZoneLabel +import com.devil.phoenixproject.data.ble.PolarH10BleManager +import com.devil.phoenixproject.data.repository.WorkoutRepository +import com.devil.phoenixproject.presentation.components.PolarSessionAnalyticsCard import com.devil.phoenixproject.util.KmpUtils import kotlin.math.cos import kotlin.math.roundToInt @@ -108,9 +111,13 @@ fun SetSummaryCard( // summary preference, so this card's auto-continue countdown must be inert there. // An explicit tap (onContinue) still proceeds. Non-terminal summaries are unchanged. isTerminalSummary: Boolean = false, + polarBleManager: PolarH10BleManager? = null, + workoutRepository: WorkoutRepository? = null, ) { // State for RPE tracking var loggedRpe by remember { mutableStateOf(null) } + val isCooldownActive = polarBleManager?.isCooldownActive?.collectAsState()?.value ?: false + val effectiveHoldAutoContinue = holdAutoContinue || isCooldownActive // Issue #142: Use a unique key derived from the summary to ensure countdown resets for each new set. // Using durationMs and repCount as a composite identifier since these are unique per set completion. @@ -123,7 +130,7 @@ fun SetSummaryCard( autoplayEnabled = autoplayEnabled, summaryCountdownSeconds = summaryCountdownSeconds, isHistoryView = isHistoryView, - holdAutoContinue = holdAutoContinue, + holdAutoContinue = effectiveHoldAutoContinue, isTerminalSummary = isTerminalSummary, ) ) { @@ -141,12 +148,12 @@ fun SetSummaryCard( // Issue #1018: keyed on holdAutoContinue and inert while the hold is set, so opening // Add Exercise stops the countdown and a dialog dismiss never restarts it. // Issue #1226: also inert at the terminal routine summary (isTerminalSummary). - LaunchedEffect(summaryKey, autoplayEnabled, summaryCountdownSeconds, holdAutoContinue, isTerminalSummary) { + LaunchedEffect(summaryKey, autoplayEnabled, summaryCountdownSeconds, effectiveHoldAutoContinue, isTerminalSummary) { if (shouldAutoContinueSetSummary( autoplayEnabled = autoplayEnabled, summaryCountdownSeconds = summaryCountdownSeconds, isHistoryView = isHistoryView, - holdAutoContinue = holdAutoContinue, + holdAutoContinue = effectiveHoldAutoContinue, isTerminalSummary = isTerminalSummary, ) ) { @@ -427,6 +434,17 @@ fun SetSummaryCard( } } + // Polar H10 Session Analytics Card (live view only; history renders at bottom below rep details) + if (!isHistoryView) { + PolarSessionAnalyticsCard( + sessionId = summary.sessionId, + polarBleManager = polarBleManager, + workoutRepository = workoutRepository, + showCooldown = false, + modifier = Modifier.padding(bottom = 12.dp), + ) + } + // Done/Continue button - only show in live view if (!isHistoryView) { // Issue #1018: secondary Add Exercise action above the primary button (terminal @@ -1024,7 +1042,7 @@ private fun VelocitySummaryCard(biomechanics: BiomechanicsSetSummary) { Box( modifier = Modifier .size(8.dp) - .background(velocityZoneColor(zone), RoundedCornerShape(2.dp)), + .background(velocityZoneColor(zone), MaterialTheme.shapes.extraSmall), ) Text( "${velocityZoneLabel(zone)}: $count", diff --git a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt index 27e98f27b..8cab50ee4 100644 --- a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt @@ -89,6 +89,7 @@ import com.devil.phoenixproject.data.repository.UserProfileRepository import com.devil.phoenixproject.data.sync.SyncTriggerManager import com.devil.phoenixproject.domain.model.BleCompatibilitySetting import com.devil.phoenixproject.presentation.components.DestructiveConfirmDialog +import com.devil.phoenixproject.presentation.components.PolarH10SettingsCard import com.devil.phoenixproject.presentation.components.ProfileRecoverySettingsSection import com.devil.phoenixproject.ui.theme.* import com.devil.phoenixproject.util.BackupDestination @@ -1150,6 +1151,9 @@ fun SettingsTab( ) } + // Polar H10 Heart Rate & Telemetry Sensor + PolarH10SettingsCard() + // Developer Tools Section - Material 3 Expressive Card( modifier = Modifier diff --git a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt index ff109bcad..c612c654e 100644 --- a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt @@ -44,6 +44,9 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import com.devil.phoenixproject.data.ble.PolarH10BleManager +import com.devil.phoenixproject.data.repository.WorkoutRepository +import com.devil.phoenixproject.presentation.components.PolarSessionAnalyticsCard import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -142,6 +145,8 @@ fun WorkoutTab( // Issue #1227: TOKEN -> LABEL custom-equipment overlay for the picker dialogs. The host // passes the active profile's overlay; the empty default is for previews/tests only. customEquipmentOverlay: Map = emptyMap(), + polarBleManager: PolarH10BleManager? = null, + workoutRepository: WorkoutRepository? = null, ) { val connectionState = state.connectionState val workoutState = state.workoutState @@ -284,7 +289,7 @@ fun WorkoutTab( onReconnect = onReconnectWorkoutTeardown, ) - if (connectionState is ConnectionState.Connected) { + if (connectionState is ConnectionState.Connected || workoutState is WorkoutState.Completed) { when (workoutState) { is WorkoutState.Error -> { ErrorCard(message = workoutState.message) @@ -297,6 +302,8 @@ fun WorkoutTab( onStartNextExercise = onStartNextExercise, onResetForNewWorkout = onResetForNewWorkout, machineTeardownState = machineTeardownState, + polarBleManager = polarBleManager, + workoutRepository = workoutRepository, ) } @@ -423,6 +430,8 @@ fun WorkoutTab( workoutMode = workoutParameters.programMode.displayName, weightUnit = weightUnit, kgToDisplay = kgToDisplay, + polarBleManager = polarBleManager, + workoutRepository = workoutRepository, onContinue = { // Intercept dismissal once to nudge tagging of an untagged // Just Lift set. If the user already decided, or the set isn't a @@ -941,6 +950,8 @@ private fun CompletedCard( onStartNextExercise: () -> Unit, onResetForNewWorkout: () -> Unit, machineTeardownState: MachineTeardownState, + polarBleManager: PolarH10BleManager? = null, + workoutRepository: WorkoutRepository? = null, ) { // workout-setup-16: spring scaleIn entrance on the CheckCircle icon. // reduceMotion: EnterTransition.None — icon appears instantly (complete static final state). @@ -1026,32 +1037,39 @@ private fun CompletedCard( style = MaterialTheme.typography.bodyMedium, color = phoenixStructuralContentColor(MaterialTheme.colorScheme), ) + } + } + } - Spacer(Modifier.height(Spacing.medium)) + // Polar H10 Session Analytics & Cooldown Protocol (rendered below exercise cards, above action button) + PolarSessionAnalyticsCard( + modifier = Modifier.padding(vertical = Spacing.small), + polarBleManager = polarBleManager, + workoutRepository = workoutRepository, + ) - Button( - onClick = onStartNextExercise, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - enabled = nextExerciseStartGate.startEnabled, - shape = MaterialTheme.shapes.medium, - elevation = ButtonDefaults.buttonElevation( - defaultElevation = 4.dp, - pressedElevation = 2.dp, - ), - ) { - Text( - if (nextExerciseStartGate.label == StartGateLabel.FINISHING_PREVIOUS_WORKOUT) { - stringResource(Res.string.workout_teardown_finishing) - } else { - "Start Next Exercise" - }, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) - } - } + if (hasMoreExercises && nextExercise != null) { + Button( + onClick = onStartNextExercise, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + enabled = nextExerciseStartGate.startEnabled, + shape = MaterialTheme.shapes.medium, + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 4.dp, + pressedElevation = 2.dp, + ), + ) { + Text( + if (nextExerciseStartGate.label == StartGateLabel.FINISHING_PREVIOUS_WORKOUT) { + stringResource(Res.string.workout_teardown_finishing) + } else { + "Start Next Exercise" + }, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) } } else { // Last exercise or not a routine - show "Start New Workout"