From b7eecd2dcb84803450ca8517b10bd39ce79bf149 Mon Sep 17 00:00:00 2001 From: StuGotz Date: Mon, 19 Oct 2026 19:00:14 -0500 Subject: [PATCH 01/25] feat(ble): add Polar H10 telemetry models and protocol byte parsers --- .../data/ble/PolarProtocolParser.kt | 224 +++++++++++++ .../domain/model/PolarModels.kt | 54 +++ .../devil/phoenixproject/util/BleConstants.kt | 36 ++ .../data/ble/PolarProtocolParserTest.kt | 316 ++++++++++++++++++ 4 files changed, 630 insertions(+) create mode 100644 shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParser.kt create mode 100644 shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/PolarModels.kt create mode 100644 shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParserTest.kt 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..4a242034f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParser.kt @@ -0,0 +1,224 @@ +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 contactDetected = (flags and 0x06) == 0x06 + 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 + } + + 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 16-bit signed LE 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? { + return when (frameType) { + 0x00 -> { + // Uncompressed 16-bit signed LE samples (6 bytes per sample) + val samples = mutableListOf() + var offset = 10 + while (offset + 6 <= data.size) { + val x = getInt16LE(data, offset) + val y = getInt16LE(data, offset + 2) + val z = getInt16LE(data, offset + 4) + samples.add(PolarAccSample(x, y, z)) + offset += 6 + } + PolarPmdMeasurement.Acc( + sensorTimestampNs = timestampNs, + samples = samples, + ) + } + 0x80 -> { + // Delta-compressed frame + if (data.size < 18) return null + var currentX = getInt16LE(data, 10) + var currentY = getInt16LE(data, 12) + var currentZ = getInt16LE(data, 14) + val deltaSizeBits = data[16].toInt() and 0xFF + val sampleCount = data[17].toInt() and 0xFF + + val samples = mutableListOf() + // The baseline sample at index 10..15 is the initial reference sample + samples.add(PolarAccSample(currentX, currentY, currentZ)) + + if (deltaSizeBits == 0 || sampleCount == 0 || deltaSizeBits > 16) { + return PolarPmdMeasurement.Acc(timestampNs, samples) + } + + val totalAvailableBits = (data.size - 18) * 8 + var bitPos = 0 + for (i in 0 until sampleCount) { + if (bitPos + (deltaSizeBits * 3) > totalAvailableBits) break + val deltaX = readSignedBits(data, 18, bitPos, deltaSizeBits) + bitPos += deltaSizeBits + val deltaY = readSignedBits(data, 18, bitPos, deltaSizeBits) + bitPos += deltaSizeBits + val deltaZ = readSignedBits(data, 18, bitPos, deltaSizeBits) + bitPos += deltaSizeBits + + currentX += deltaX + currentY += deltaY + currentZ += deltaZ + samples.add(PolarAccSample(currentX, currentY, currentZ)) + } + + PolarPmdMeasurement.Acc( + sensorTimestampNs = timestampNs, + samples = samples, + ) + } + else -> null + } +} 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..0e5066331 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/PolarModels.kt @@ -0,0 +1,54 @@ +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(), +) + +/** + * 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/util/BleConstants.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt index 8c576298e..d58a2f48a 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,40 @@ 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 + + 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 STOP_ECG = byteArrayOf(0x03, 0x00) + val STOP_ACC = byteArrayOf(0x03, 0x02) + } + } } + 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..a5684f0b6 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParserTest.kt @@ -0,0 +1,316 @@ +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) + val notDetected = parsePolarHrMeasurement(byteArrayOf(0x04, 60)) + assertNotNull(notDetected) + assertFalse(notDetected.contactDetected) + + // Flags: 0x00 (contact not supported) + val notSupported = parsePolarHrMeasurement(byteArrayOf(0x00, 60)) + assertNotNull(notSupported) + assertFalse(notSupported.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, 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: 0x00 + // 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] = 0x00 // uncompressed + + // 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]) + } +} From 9212b472ac7c48f67f27d58707e8d70629202e41 Mon Sep 17 00:00:00 2001 From: StuGotz Date: Mon, 19 Oct 2026 19:28:27 -0500 Subject: [PATCH 02/25] feat(ble): add PolarH10BleManager for concurrent HR, RR, ECG, and ACC streaming --- .../data/ble/PolarH10BleManager.kt | 597 ++++++++++++++++++ .../com/devil/phoenixproject/di/DataModule.kt | 6 +- .../domain/model/PolarModels.kt | 13 + .../devil/phoenixproject/util/BleConstants.kt | 5 + .../data/ble/PolarH10BleManagerTest.kt | 187 ++++++ 5 files changed, 807 insertions(+), 1 deletion(-) create mode 100644 shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt create mode 100644 shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt 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..5f1c2fa11 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -0,0 +1,597 @@ +package com.devil.phoenixproject.data.ble + +import co.touchlab.kermit.Logger +import com.devil.phoenixproject.data.repository.ScannedDevice +import com.devil.phoenixproject.domain.model.ConnectionState +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.currentTimeMillis +import com.devil.phoenixproject.util.BleConstants +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 kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +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.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull + +/** + * 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 scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + private val timeSource: () -> Long = ::currentTimeMillis, +) { + private val log = Logger.withTag("PolarH10BleManager") + + // ===== Connection & Discovery State ===== + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _scannedDevices = MutableStateFlow>(emptyList()) + val scannedDevices: StateFlow> = _scannedDevices.asStateFlow() + + // ===== Telemetry State & Streams ===== + private val _batteryLevel = MutableStateFlow(null) + val batteryLevel: StateFlow = _batteryLevel.asStateFlow() + + private val _hrMeasurement = MutableStateFlow(null) + val hrMeasurement: StateFlow = _hrMeasurement.asStateFlow() + + private val _hrMeasurements = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val hrMeasurements: SharedFlow = _hrMeasurements.asSharedFlow() + + private val _pmdMeasurement = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 128, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val pmdMeasurement: SharedFlow = _pmdMeasurement.asSharedFlow() + + 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 _rrBeats = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val rrBeats: SharedFlow = _rrBeats.asSharedFlow() + + private val _rrClockTimestampMs = MutableStateFlow(null) + val rrClockTimestampMs: StateFlow = _rrClockTimestampMs.asStateFlow() + + // ===== Internal Connection References ===== + private var peripheral: Peripheral? = null + val currentPeripheral: Peripheral? get() = peripheral + + private var scanJob: Job? = null + private val discoveredAdvertisements = mutableMapOf() + + private var stateObserverJob: Job? = null + private var hrObserveJob: Job? = null + private var batteryObserveJob: Job? = null + + // ===== PMD Stream Control ===== + private val pmdMutex = Mutex() + private var pmdControlPointJob: Job? = null + private var pmdDataJob: Job? = null + private var pmdCpSubscribed = CompletableDeferred() + private var pmdDataSubscribed = CompletableDeferred() + private var isEcgStreaming = false + private var isAccStreaming = false + + val isEcgActive: Boolean get() = isEcgStreaming + val isAccActive: Boolean get() = isAccStreaming + + // ===== 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" } + scanJob?.cancel() + scanJob = null + _scannedDevices.value = emptyList() + discoveredAdvertisements.clear() + + _connectionState.value = ConnectionState.Scanning + + var currentDevices = emptyList() + + scanJob = scope.launch { + try { + withTimeoutOrNull(BleConstants.SCAN_TIMEOUT_MS) { + Scanner {} + .advertisements + .filter { adv -> isPolarH10Advertisement(adv) } + .collect { adv -> + val identifier = adv.identifier.toString() + 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)" } + _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) { + _connectionState.value = ConnectionState.Disconnected + } + } + + /** + * Connect to the selected Polar H10 device. + */ + suspend fun connect(device: ScannedDevice): Result { + log.i { "Connecting to Polar H10: ${device.name} (${device.address})" } + stopScanning() + + val advertisement = discoveredAdvertisements[device.address] + ?: discoveredAdvertisements.values.firstOrNull { it.identifier.toString() == device.address } + ?: return Result.failure(IllegalStateException("Polar device ${device.address} not found in scanned cache")) + + return try { + cleanupExistingConnection() + _connectionState.value = ConnectionState.Connecting + + val p = Peripheral(advertisement) + peripheral = p + + stateObserverJob = scope.launch { + p.state.collect { state -> + log.d { "Polar H10 state: $state" } + when (state) { + is State.Connecting -> { + _connectionState.value = ConnectionState.Connecting + } + is State.Connected -> { + _connectionState.value = ConnectionState.Connected( + deviceName = device.name, + deviceAddress = device.address, + ) + onDeviceConnected(p) + } + is State.Disconnecting -> { + // Transient disconnecting state + } + is State.Disconnected -> { + _connectionState.value = ConnectionState.Disconnected + cleanupSessionTelemetry() + } + } + } + } + + withTimeout(BleConstants.CONNECTION_TIMEOUT_MS) { + p.connect() + p.state.first { it is State.Connected } + } + + Result.success(Unit) + } catch (e: Exception) { + e.rethrowIfCancellation() + log.e(e) { "Failed to connect to Polar H10: ${e.message}" } + cleanupExistingConnection() + _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" } + + // 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 -> + parsePolarBatteryLevel(bytes)?.let { _batteryLevel.value = it } + } + } catch (e: Exception) { + e.rethrowIfCancellation() + } + } + + // 3. Subscribe to HR/RR notifications (0x2A37) + startHrObservation(p) + } + + private fun startHrObservation(p: Peripheral) { + hrObserveJob?.cancel() + hrObserveJob = scope.launch { + try { + log.i { "Subscribing to Polar HR & RR notifications (0x2A37)" } + p.observe(BleConstants.Polar.hrMeasurementCharacteristic) + .catch { e -> + e.rethrowIfCancellation() + log.e { "Polar HR observation error: ${e.message}" } + } + .collect { data -> + val measurement = parsePolarHrMeasurement(data) ?: return@collect + _hrMeasurement.value = measurement + _hrMeasurements.emit(measurement) + + if (measurement.rrIntervalsMs.isNotEmpty()) { + processRrIntervals(measurement) + } + } + } catch (e: Exception) { + e.rethrowIfCancellation() + log.e(e) { "Failed to maintain Polar HR observation: ${e.message}" } + } + } + } + + /** + * 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) { + val rrs = measurement.rrIntervalsMs + if (rrs.isEmpty()) return + + val nowMs = timeSource() + val previousBeat = lastBeatTimestampMs + + val startingTs: Long = if (previousBeat == null || (nowMs - previousBeat) > 5000L || previousBeat > nowMs + 5000L) { + val totalRrDurationMs = rrs.sum().toLong() + nowMs - 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) + } + + lastBeatTimestampMs = runningTs + _rrClockTimestampMs.value = runningTs + } + + private suspend fun ensurePmdSubscribed(p: Peripheral): Boolean = pmdMutex.withLock { + if (pmdControlPointJob?.isActive == true && pmdDataJob?.isActive == true) { + return true + } + + pmdControlPointJob?.cancel() + pmdDataJob?.cancel() + + pmdCpSubscribed = CompletableDeferred() + pmdDataSubscribed = 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" } + pmdCpSubscribed.complete(Unit) + }) + .catch { e -> + e.rethrowIfCancellation() + log.w { "PMD CP observe error: ${e.message}" } + } + .collect { cpData -> + log.d { "PMD CP response: ${cpData.size} bytes" } + } + } catch (e: Exception) { + e.rethrowIfCancellation() + log.e(e) { "PMD CP observer failed: ${e.message}" } + if (!pmdCpSubscribed.isCompleted) { + pmdCpSubscribed.completeExceptionally(e) + } + } + } + + // 2. Subscribe to PMD Data (FB005C82) + pmdDataJob = scope.launch { + try { + p.observe(BleConstants.Polar.pmdDataCharacteristic, onSubscription = { + log.i { "PMD Data (FB005C82) subscribed" } + pmdDataSubscribed.complete(Unit) + }) + .catch { e -> + e.rethrowIfCancellation() + log.w { "PMD Data observe error: ${e.message}" } + } + .collect { data -> + val measurement = parsePolarPmdData(data) ?: return@collect + _pmdMeasurement.tryEmit(measurement) + when (measurement) { + is PolarPmdMeasurement.Ecg -> _ecgStream.tryEmit(measurement) + is PolarPmdMeasurement.Acc -> _accStream.tryEmit(measurement) + } + } + } catch (e: Exception) { + e.rethrowIfCancellation() + log.e(e) { "PMD Data observer failed: ${e.message}" } + if (!pmdDataSubscribed.isCompleted) { + pmdDataSubscribed.completeExceptionally(e) + } + } + } + + withTimeout(BleConstants.GATT_OPERATION_TIMEOUT_MS) { + pmdCpSubscribed.await() + pmdDataSubscribed.await() + } + true + } + + /** + * 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" } + ensurePmdSubscribed(p) + p.write( + characteristic = BleConstants.Polar.pmdControlPointCharacteristic, + data = BleConstants.Polar.PMD_START_ECG_CMD, + writeType = WriteType.WithResponse, + ) + isEcgStreaming = true + Result.success(Unit) + } catch (e: Exception) { + e.rethrowIfCancellation() + log.e(e) { "Failed to start ECG stream: ${e.message}" } + 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" } + p.write( + characteristic = BleConstants.Polar.pmdControlPointCharacteristic, + data = BleConstants.Polar.PMD_STOP_ECG_CMD, + writeType = WriteType.WithResponse, + ) + isEcgStreaming = false + Result.success(Unit) + } catch (e: Exception) { + e.rethrowIfCancellation() + log.e(e) { "Failed to stop ECG stream: ${e.message}" } + 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. + */ + suspend fun startAccStream(): Result { + val p = peripheral ?: return Result.failure(IllegalStateException("Polar H10 not connected")) + return try { + log.i { "Starting Polar H10 ACC stream" } + ensurePmdSubscribed(p) + p.write( + characteristic = BleConstants.Polar.pmdControlPointCharacteristic, + data = BleConstants.Polar.PMD_START_ACC_CMD, + writeType = WriteType.WithResponse, + ) + isAccStreaming = true + Result.success(Unit) + } catch (e: Exception) { + e.rethrowIfCancellation() + log.e(e) { "Failed to start ACC stream: ${e.message}" } + 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" } + p.write( + characteristic = BleConstants.Polar.pmdControlPointCharacteristic, + data = BleConstants.Polar.PMD_STOP_ACC_CMD, + writeType = WriteType.WithResponse, + ) + isAccStreaming = false + Result.success(Unit) + } catch (e: Exception) { + e.rethrowIfCancellation() + log.e(e) { "Failed to stop ACC stream: ${e.message}" } + Result.failure(e) + } + } + + /** + * Disconnect from the Polar H10 device and clean up all active telemetry streams. + */ + suspend fun disconnect() { + val p = peripheral + if (p != null) { + log.i { "Disconnecting Polar H10" } + try { + if (isEcgStreaming) { + try { stopEcgStream() } catch (_: Exception) {} + } + if (isAccStreaming) { + try { stopAccStream() } catch (_: Exception) {} + } + p.disconnect() + } catch (e: Exception) { + e.rethrowIfCancellation() + log.w { "Error during Polar disconnect: ${e.message}" } + } + } + cleanupExistingConnection() + _connectionState.value = ConnectionState.Disconnected + } + + private fun cleanupSessionTelemetry() { + hrObserveJob?.cancel() + hrObserveJob = null + batteryObserveJob?.cancel() + batteryObserveJob = null + + pmdControlPointJob?.cancel() + pmdControlPointJob = null + pmdDataJob?.cancel() + pmdDataJob = null + + isEcgStreaming = false + isAccStreaming = false + + lastBeatTimestampMs = null + _batteryLevel.value = null + _hrMeasurement.value = null + _rrClockTimestampMs.value = null + } + + private fun cleanupExistingConnection() { + stateObserverJob?.cancel() + stateObserverJob = null + cleanupSessionTelemetry() + peripheral = null + } + + fun shutdown() { + scanJob?.cancel() + scanJob = null + stateObserverJob?.cancel() + stateObserverJob = null + cleanupSessionTelemetry() + val p = peripheral + peripheral = null + if (p != null) { + scope.launch { + try { p.disconnect() } catch (_: Exception) {} + } + } + } + + internal fun isPolarH10Advertisement(adv: Advertisement): Boolean { + val name = adv.name + if (name != null && name.startsWith("Polar H10", ignoreCase = true)) { + return true + } + val hrUuidPrefix = "0000180d" + return adv.uuids.any { uuid -> + val s = uuid.toString().lowercase() + s.startsWith(hrUuidPrefix) || s == BleConstants.Polar.HR_SERVICE_UUID_STRING + } + } +} 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..9c8a2ec4c 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt @@ -1,7 +1,8 @@ -package com.devil.phoenixproject.di +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 @@ -169,4 +170,7 @@ val dataModule = module { single { SqlDelightExternalExerciseTemplateRepository(get()) } single { SqlDelightIntegrationSyncCursorRepository(get()) } single { HealthBackfillManager(get(), get(), get(), get()) } + + // Polar H10 BLE Manager + single { PolarH10BleManager() } onClose { (it as? PolarH10BleManager)?.shutdown() } } 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 index 0e5066331..d8f8ba233 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/PolarModels.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/PolarModels.kt @@ -15,6 +15,19 @@ data class PolarHrMeasurement( 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). */ 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 d58a2f48a..bae721320 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt @@ -200,6 +200,11 @@ object BleConstants { val STOP_ECG = byteArrayOf(0x03, 0x00) val STOP_ACC = byteArrayOf(0x03, 0x02) } + + val PMD_START_ECG_CMD: ByteArray get() = Commands.START_ECG + val PMD_START_ACC_CMD: ByteArray get() = Commands.START_ACC + val PMD_STOP_ECG_CMD: ByteArray get() = Commands.STOP_ECG + val PMD_STOP_ACC_CMD: ByteArray get() = Commands.STOP_ACC } } 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..d634ee599 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt @@ -0,0 +1,187 @@ +package com.devil.phoenixproject.data.ble + +import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.domain.model.PolarHrMeasurement +import com.devil.phoenixproject.domain.model.PolarRrBeat +import com.devil.phoenixproject.util.BleConstants +import com.juul.kable.Advertisement +import com.juul.kable.Identifier +import com.juul.kable.ManufacturerData +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch + +@OptIn(ExperimentalUuidApi::class) +class PolarH10BleManagerTest { + + private class FakeAdvertisement( + override val name: String? = null, + override val uuids: List = emptyList(), + override val rssi: Int = -60, + ) : Advertisement { + override val peripheralName: String? get() = name + override val identifier: Identifier get() = "00:11:22:33:44:55" + override val isConnectable: Boolean get() = true + override val txPower: Int? get() = null + override val manufacturerData: ManufacturerData? get() = null + override fun serviceData(uuid: Uuid): ByteArray? = null + override fun manufacturerData(companyIdentifierCode: Int): ByteArray? = null + } + + // ==================== Advertisement Filter Tests ==================== + + @Test + fun `isPolarH10Advertisement matches Polar H10 device names case-insensitively`() { + val manager = PolarH10BleManager() + + assertTrue(manager.isPolarH10Advertisement(FakeAdvertisement(name = "Polar H10 12345678"))) + assertTrue(manager.isPolarH10Advertisement(FakeAdvertisement(name = "polar h10 9999"))) + assertTrue(manager.isPolarH10Advertisement(FakeAdvertisement(name = "POLAR H10"))) + + assertFalse(manager.isPolarH10Advertisement(FakeAdvertisement(name = "Vee_Trainer"))) + assertFalse(manager.isPolarH10Advertisement(FakeAdvertisement(name = "VIT-200"))) + assertFalse(manager.isPolarH10Advertisement(FakeAdvertisement(name = null))) + } + + @Test + fun `isPolarH10Advertisement matches standard Heart Rate service UUID 0x180D`() { + val manager = PolarH10BleManager() + + val hrUuid = BleConstants.Polar.HR_SERVICE_UUID + assertTrue(manager.isPolarH10Advertisement(FakeAdvertisement(name = null, uuids = listOf(hrUuid)))) + + val nusUuid = BleConstants.NUS_SERVICE_UUID + assertFalse(manager.isPolarH10Advertisement(FakeAdvertisement(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.rrClockTimestampMs.value) + assertFalse(manager.isEcgActive) + assertFalse(manager.isAccActive) + } + + // ==================== 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) + assertEquals(100_000L, manager.rrClockTimestampMs.value) + + // 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) + assertEquals(100_900L, manager.rrClockTimestampMs.value) + + 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) + assertEquals(65_000L, manager.rrClockTimestampMs.value) + + collectJob.cancel() + testScope.cancel() + } + + @Test + fun `processRrIntervals ignores empty RR lists without modifying clock`() { + val manager = PolarH10BleManager() + manager.processRrIntervals(PolarHrMeasurement(hr = 80, contactDetected = true, rrIntervalsMs = emptyList())) + assertNull(manager.rrClockTimestampMs.value) + } +} From 358a6b17e1e66772b16969cd99264dc6e12f899e Mon Sep 17 00:00:00 2001 From: StuGotz Date: Mon, 19 Oct 2026 19:50:20 -0500 Subject: [PATCH 03/25] feat(ui): add Polar H10 settings pairing card and live workout telemetry strip --- .../components/PolarH10SettingsCard.kt | 414 ++++++++++++++++++ .../components/PolarH10WorkoutStrip.kt | 351 +++++++++++++++ .../presentation/util/PolarTelemetryUtils.kt | 67 +++ .../presentation/PolarH10WiringTest.kt | 37 ++ .../util/PolarTelemetryUtilsTest.kt | 67 +++ .../screen/ActiveWorkoutScreen.kt | 5 + .../presentation/screen/SettingsTab.kt | 4 + 7 files changed, 945 insertions(+) create mode 100644 shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt create mode 100644 shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt create mode 100644 shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtils.kt create mode 100644 shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/PolarH10WiringTest.kt create mode 100644 shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtilsTest.kt diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt new file mode 100644 index 000000000..ebedc4cfb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt @@ -0,0 +1,414 @@ +package com.devil.phoenixproject.presentation.components + +import androidx.compose.animation.AnimatedVisibility +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.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.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.domain.model.PolarAccSample +import com.devil.phoenixproject.ui.theme.Spacing +import com.devil.phoenixproject.presentation.util.formatRmssdMs +import com.devil.phoenixproject.presentation.util.formatRrMs +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + +@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() + + var isEcgEnabled by remember { mutableStateOf(false) } + var isAccEnabled by remember { mutableStateOf(false) } + var latestEcgSampleUv by remember { mutableStateOf(null) } + var latestAccSample by remember { mutableStateOf(null) } + + // Sync switch states with manager status + LaunchedEffect(connectionState) { + if (connectionState !is ConnectionState.Connected) { + isEcgEnabled = false + isAccEnabled = false + latestEcgSampleUv = null + latestAccSample = null + } + } + + // Collect preview samples when test toggles are active + LaunchedEffect(isEcgEnabled) { + if (isEcgEnabled) { + polarBleManager.ecgStream.collectLatest { ecg -> + latestEcgSampleUv = ecg.samplesMicrovolts.lastOrNull() + } + } + } + + LaunchedEffect(isAccEnabled) { + if (isAccEnabled) { + polarBleManager.accStream.collectLatest { acc -> + latestAccSample = acc.samples.lastOrNull() + } + } + } + + 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 = Color.White, + 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)) + + // 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)) + Button( + onClick = { scope.launch { polarBleManager.startScanning() } }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Default.Refresh, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("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 = hrMeasurement?.contactDetected == true + 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, + ) + } + } + + Spacer(modifier = Modifier.height(Spacing.small)) + + // Stream Testing Toggles + Text( + "Live Telemetry Streams (Testing)", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // 130 Hz ECG toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("130 Hz ECG Stream", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + Text( + if (isEcgEnabled && latestEcgSampleUv != null) { + "Active: ${latestEcgSampleUv} µV" + } else { + "14-bit microvolt voltage stream" + }, + style = MaterialTheme.typography.bodySmall, + color = if (isEcgEnabled) Color(0xFF00C853) else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = isEcgEnabled, + onCheckedChange = { enable -> + isEcgEnabled = enable + scope.launch { + if (enable) polarBleManager.startEcgStream() else polarBleManager.stopEcgStream() + } + }, + ) + } + + // 50 Hz ACC toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("50 Hz Chest ACC Stream", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + Text( + if (isAccEnabled && latestAccSample != null) { + "X: ${latestAccSample?.xMg} Y: ${latestAccSample?.yMg} Z: ${latestAccSample?.zMg} mG" + } else { + "16-bit ±8G tri-axial chest acceleration" + }, + style = MaterialTheme.typography.bodySmall, + color = if (isAccEnabled) Color(0xFF00C853) else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = isAccEnabled, + onCheckedChange = { enable -> + isAccEnabled = enable + scope.launch { + if (enable) polarBleManager.startAccStream() else polarBleManager.stopAccStream() + } + }, + ) + } + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt new file mode 100644 index 000000000..28f65852e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt @@ -0,0 +1,351 @@ +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.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.presentation.util.computeRollingRmssd +import com.devil.phoenixproject.presentation.util.formatRmssdMs +import com.devil.phoenixproject.presentation.util.formatRrMs +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +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 = koinInject(), +) { + 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() + + // 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() } + + // Latest accelerometer sample + var latestAccSample by remember { mutableStateOf(null) } + + // 1. Auto-start ECG & ACC on enter, cleanly stop on dispose + LaunchedEffect(Unit) { + polarBleManager.startEcgStream() + polarBleManager.startAccStream() + } + + DisposableEffect(Unit) { + onDispose { + scope.launch { + polarBleManager.stopEcgStream() + polarBleManager.stopAccStream() + } + } + } + + // 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.collectLatest { ecg -> + ecgSamples.addAll(ecg.samplesMicrovolts) + while (ecgSamples.size > MAX_ECG_DISPLAY_SAMPLES) { + ecgSamples.removeAt(0) + } + } + } + + LaunchedEffect(Unit) { + polarBleManager.accStream.collectLatest { acc -> + latestAccSample = acc.samples.lastOrNull() + } + } + + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(12.dp), + 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), + ) { + val hasContact = hrMeasurement?.contactDetected == true + 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 = formatRrMs(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 = formatRmssdMs(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(RoundedCornerShape(6.dp)) + .background(Color(0xFF0D1B2A)) + .border(0.5.dp, Color(0xFF1E3A5F), RoundedCornerShape(6.dp)), + contentAlignment = Alignment.Center, + ) { + EcgWaveformCanvas( + samples = ecgSamples, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(horizontal = 2.dp, vertical = 2.dp), + ) + } + + // Section 4: Live Chest Acceleration (mG) + Column( + modifier = Modifier.padding(start = 6.dp), + horizontalAlignment = Alignment.End, + ) { + val acc = latestAccSample + if (acc != null) { + val magnitudeG = sqrt( + (acc.xMg.toDouble() * acc.xMg + acc.yMg.toDouble() * acc.yMg + acc.zMg.toDouble() * acc.zMg), + ) / 1000.0 + Text( + text = "${(magnitudeG * 100).roundToInt() / 100.0} G", + style = MaterialTheme.typography.labelSmall, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "ACC 50Hz", + style = MaterialTheme.typography.labelSmall, + fontSize = 8.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = "ACC", + style = MaterialTheme.typography.labelSmall, + fontSize = 9.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "—", + style = MaterialTheme.typography.labelSmall, + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +/** + * Real-time hardware-accelerated Canvas waveform plotting 130 Hz ECG samples (µV). + */ +@Composable +private 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/commonMain/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtils.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtils.kt new file mode 100644 index 000000000..31da25c41 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtils.kt @@ -0,0 +1,67 @@ +package com.devil.phoenixproject.presentation.util + +import com.devil.phoenixproject.domain.model.PolarRrBeat +import kotlin.math.roundToInt +import kotlin.math.sqrt + +/** + * Computes rolling RMSSD (Root Mean Square of Successive Differences) in milliseconds + * over a sliding time window (default 60 seconds, min 30 seconds recommended). + * + * Successive differences are computed on adjacent [PolarRrBeat] entries that fall within + * [currentTimeMs - windowMs, currentTimeMs]. Beats separated by excessive time gaps (> 3500ms) + * or with non-physiological intervals (< 250ms or > 2500ms) are excluded from difference pairs + * to prevent dropped-beat artifact inflation. + * + * @param beats Ordered list of [PolarRrBeat] events. + * @param windowMs Time window in milliseconds (default 60,000ms = 60s). + * @param currentTimeMs Reference timestamp for the window edge. + * @return RMSSD in milliseconds, or null if fewer than 2 consecutive valid beats are available. + */ +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 } + if (windowBeats.size < 2) return null + + var sumSquaredDiffs = 0.0 + var count = 0 + + for (i in 0 until windowBeats.size - 1) { + val b1 = windowBeats[i] + val b2 = windowBeats[i + 1] + + // Validate individual RR intervals (250ms = 240 BPM, 2500ms = 24 BPM) + if (b1.rrIntervalMs !in 250.0..2500.0 || b2.rrIntervalMs !in 250.0..2500.0) { + continue + } + + // Validate inter-beat time gap (beats should be contiguous within reasonable tolerance) + val timeGap = b2.timestampMs - b1.timestampMs + if (timeGap !in 200..3500) { + continue + } + + val diff = b2.rrIntervalMs - b1.rrIntervalMs + sumSquaredDiffs += diff * diff + count++ + } + + if (count == 0) return null + return sqrt(sumSquaredDiffs / count) +} + +/** + * Format milliseconds to a clean integer string representation. + */ +fun formatRrMs(rrMs: Double?): String = rrMs?.let { "${it.roundToInt()} ms" } ?: "—" + +/** + * Format RMSSD to a clean integer string representation. + */ +fun formatRmssdMs(rmssd: Double?): String = rmssd?.let { "${it.roundToInt()} ms" } ?: "—" diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/PolarH10WiringTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/PolarH10WiringTest.kt new file mode 100644 index 000000000..7a3843f04 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/PolarH10WiringTest.kt @@ -0,0 +1,37 @@ +package com.devil.phoenixproject.presentation + +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class PolarH10WiringTest { + + @Test + fun settingsTabIncludesPolarH10SettingsCard() { + val src = readProjectFile("src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt") + assertNotNull(src, "SettingsTab.kt must be readable") + assertTrue( + src.contains("import com.devil.phoenixproject.presentation.components.PolarH10SettingsCard"), + "SettingsTab.kt must import PolarH10SettingsCard", + ) + assertTrue( + src.contains("PolarH10SettingsCard()"), + "SettingsTab.kt must render PolarH10SettingsCard", + ) + } + + @Test + fun activeWorkoutScreenIncludesPolarH10WorkoutStrip() { + val src = readProjectFile("src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ActiveWorkoutScreen.kt") + assertNotNull(src, "ActiveWorkoutScreen.kt must be readable") + assertTrue( + src.contains("import com.devil.phoenixproject.presentation.components.PolarH10WorkoutStrip"), + "ActiveWorkoutScreen.kt must import PolarH10WorkoutStrip", + ) + assertTrue( + src.contains("PolarH10WorkoutStrip()"), + "ActiveWorkoutScreen.kt must render PolarH10WorkoutStrip", + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtilsTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtilsTest.kt new file mode 100644 index 000000000..b29a114a0 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtilsTest.kt @@ -0,0 +1,67 @@ +package com.devil.phoenixproject.presentation.util + +import com.devil.phoenixproject.domain.model.PolarRrBeat +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PolarTelemetryUtilsTest { + + @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", formatRmssdMs(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) + } +} 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..928e41a93 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 @@ -48,6 +48,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 @@ -528,6 +529,10 @@ fun ActiveWorkoutScreen(navController: NavController, viewModel: MainViewModel, .padding(horizontal = 16.dp, vertical = 4.dp), ) } + + // Polar H10 Live Telemetry Strip (visible when connected) + PolarH10WorkoutStrip() + WorkoutTab( state = workoutUiState, actions = workoutActions, 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 From c373e65f697d52c505122add32514b991623c4ae Mon Sep 17 00:00:00 2001 From: StuGotz Date: Mon, 19 Oct 2026 20:58:47 -0500 Subject: [PATCH 04/25] feat(polar): add Polar H10 UI, live workout strip, and 50Hz ACC delta parser --- .../data/ble/PolarH10BleManager.kt | 176 ++++++++++++++---- .../data/ble/PolarProtocolParser.kt | 105 +++++------ .../components/PolarH10SettingsCard.kt | 27 +-- .../components/PolarH10WorkoutStrip.kt | 13 +- .../devil/phoenixproject/util/BleConstants.kt | 4 + .../data/ble/PolarH10BleManagerTest.kt | 2 + .../data/ble/PolarProtocolParserTest.kt | 71 +++++++ 7 files changed, 279 insertions(+), 119 deletions(-) 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 index 5f1c2fa11..852955b23 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.delay /** * Multiplatform BLE Manager for Polar H10 heart rate sensors. @@ -94,6 +95,12 @@ class PolarH10BleManager( ) 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, @@ -117,10 +124,14 @@ class PolarH10BleManager( // ===== PMD Stream Control ===== private val pmdMutex = Mutex() + private val pmdControlPointMutex = Mutex() + private val pmdCpResponses = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) private var pmdControlPointJob: Job? = null private var pmdDataJob: Job? = null - private var pmdCpSubscribed = CompletableDeferred() - private var pmdDataSubscribed = CompletableDeferred() private var isEcgStreaming = false private var isAccStreaming = false @@ -298,6 +309,14 @@ class PolarH10BleManager( // 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}" } + } } private fun startHrObservation(p: Peripheral) { @@ -368,28 +387,29 @@ class PolarH10BleManager( pmdControlPointJob?.cancel() pmdDataJob?.cancel() - pmdCpSubscribed = CompletableDeferred() - pmdDataSubscribed = CompletableDeferred() + 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" } - pmdCpSubscribed.complete(Unit) + cpSubscribed.complete(Unit) }) .catch { e -> e.rethrowIfCancellation() log.w { "PMD CP observe error: ${e.message}" } } .collect { cpData -> - log.d { "PMD CP response: ${cpData.size} bytes" } + log.d { "PMD CP response: ${cpData.size} bytes: ${cpData.joinToString { (it.toInt() and 0xFF).toString(16) }}" } + pmdCpResponses.tryEmit(cpData) } } catch (e: Exception) { e.rethrowIfCancellation() log.e(e) { "PMD CP observer failed: ${e.message}" } - if (!pmdCpSubscribed.isCompleted) { - pmdCpSubscribed.completeExceptionally(e) + if (!cpSubscribed.isCompleted) { + cpSubscribed.completeExceptionally(e) } } } @@ -399,7 +419,7 @@ class PolarH10BleManager( try { p.observe(BleConstants.Polar.pmdDataCharacteristic, onSubscription = { log.i { "PMD Data (FB005C82) subscribed" } - pmdDataSubscribed.complete(Unit) + dataSubscribed.complete(Unit) }) .catch { e -> e.rethrowIfCancellation() @@ -409,26 +429,77 @@ class PolarH10BleManager( val measurement = parsePolarPmdData(data) ?: return@collect _pmdMeasurement.tryEmit(measurement) when (measurement) { - is PolarPmdMeasurement.Ecg -> _ecgStream.tryEmit(measurement) - is PolarPmdMeasurement.Acc -> _accStream.tryEmit(measurement) + is PolarPmdMeasurement.Ecg -> { + _latestEcg.value = measurement + _ecgStream.tryEmit(measurement) + } + is PolarPmdMeasurement.Acc -> { + _latestAcc.value = measurement + _accStream.tryEmit(measurement) + } } } } catch (e: Exception) { e.rethrowIfCancellation() log.e(e) { "PMD Data observer failed: ${e.message}" } - if (!pmdDataSubscribed.isCompleted) { - pmdDataSubscribed.completeExceptionally(e) + if (!dataSubscribed.isCompleted) { + dataSubscribed.completeExceptionally(e) } } } withTimeout(BleConstants.GATT_OPERATION_TIMEOUT_MS) { - pmdCpSubscribed.await() - pmdDataSubscribed.await() + cpSubscribed.await() + dataSubscribed.await() } true } + private suspend fun sendPmdControlCommand( + p: Peripheral, + command: ByteArray, + opCode: Byte, + measurementType: Byte, + timeoutMs: Long = 2000L, + ): Result = pmdControlPointMutex.withLock { + ensurePmdSubscribed(p) + // Dwell 50ms before writing to ensure Android GATT queue is clean + delay(50L) + + val responseDeferred = CompletableDeferred() + val responseListener = scope.launch { + 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() + } ?: 0 // If timeout waiting for indication, assume write succeeded + Result.success(status) + } catch (e: Exception) { + e.rethrowIfCancellation() + 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. @@ -437,12 +508,15 @@ class PolarH10BleManager( val p = peripheral ?: return Result.failure(IllegalStateException("Polar H10 not connected")) return try { log.i { "Starting Polar H10 ECG stream" } - ensurePmdSubscribed(p) - p.write( - characteristic = BleConstants.Polar.pmdControlPointCharacteristic, - data = BleConstants.Polar.PMD_START_ECG_CMD, - writeType = WriteType.WithResponse, - ) + val status = sendPmdControlCommand( + p = p, + command = BleConstants.Polar.PMD_START_ECG_CMD, + opCode = 0x02, + measurementType = 0x00, + ).getOrThrow() + if (status != 0) { + log.w { "Polar H10 returned non-zero status $status starting ECG stream" } + } isEcgStreaming = true Result.success(Unit) } catch (e: Exception) { @@ -459,10 +533,11 @@ class PolarH10BleManager( val p = peripheral ?: return Result.failure(IllegalStateException("Polar H10 not connected")) return try { log.i { "Stopping Polar H10 ECG stream" } - p.write( - characteristic = BleConstants.Polar.pmdControlPointCharacteristic, - data = BleConstants.Polar.PMD_STOP_ECG_CMD, - writeType = WriteType.WithResponse, + sendPmdControlCommand( + p = p, + command = BleConstants.Polar.PMD_STOP_ECG_CMD, + opCode = 0x03, + measurementType = 0x00, ) isEcgStreaming = false Result.success(Unit) @@ -476,17 +551,45 @@ class PolarH10BleManager( /** * 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" } - ensurePmdSubscribed(p) - p.write( - characteristic = BleConstants.Polar.pmdControlPointCharacteristic, - data = BleConstants.Polar.PMD_START_ACC_CMD, - writeType = WriteType.WithResponse, - ) + // 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" } + return Result.failure(IllegalStateException("Polar H10 rejected ACC start command: status $status")) + } + isAccStreaming = true Result.success(Unit) } catch (e: Exception) { @@ -503,10 +606,11 @@ class PolarH10BleManager( val p = peripheral ?: return Result.failure(IllegalStateException("Polar H10 not connected")) return try { log.i { "Stopping Polar H10 ACC stream" } - p.write( - characteristic = BleConstants.Polar.pmdControlPointCharacteristic, - data = BleConstants.Polar.PMD_STOP_ACC_CMD, - writeType = WriteType.WithResponse, + sendPmdControlCommand( + p = p, + command = BleConstants.Polar.PMD_STOP_ACC_CMD, + opCode = 0x03, + measurementType = 0x02, ) isAccStreaming = false Result.success(Unit) @@ -559,6 +663,8 @@ class PolarH10BleManager( _batteryLevel.value = null _hrMeasurement.value = null _rrClockTimestampMs.value = null + _latestEcg.value = null + _latestAcc.value = null } private fun cleanupExistingConnection() { 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 index 4a242034f..e9d88fe80 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParser.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParser.kt @@ -163,62 +163,59 @@ private fun parseEcgFrame(data: ByteArray, timestampNs: Long): PolarPmdMeasureme } private fun parseAccFrame(data: ByteArray, timestampNs: Long, frameType: Int): PolarPmdMeasurement.Acc? { - return when (frameType) { - 0x00 -> { - // Uncompressed 16-bit signed LE samples (6 bytes per sample) - val samples = mutableListOf() - var offset = 10 - while (offset + 6 <= data.size) { - val x = getInt16LE(data, offset) - val y = getInt16LE(data, offset + 2) - val z = getInt16LE(data, offset + 4) - samples.add(PolarAccSample(x, y, z)) - offset += 6 - } - PolarPmdMeasurement.Acc( - sensorTimestampNs = timestampNs, - samples = samples, - ) + val isCompressed = (frameType and 0x80) != 0 + return if (!isCompressed) { + // Uncompressed 16-bit signed LE samples (6 bytes per sample) - frameType 0x00, 0x01 + val samples = mutableListOf() + var offset = 10 + while (offset + 6 <= data.size) { + val x = getInt16LE(data, offset) + val y = getInt16LE(data, offset + 2) + val z = getInt16LE(data, offset + 4) + samples.add(PolarAccSample(x, y, z)) + offset += 6 + } + PolarPmdMeasurement.Acc( + sensorTimestampNs = timestampNs, + samples = samples, + ) + } else { + // Delta-compressed frame - frameType 0x80, 0x81 + if (data.size < 18) return null + var currentX = getInt16LE(data, 10) + var currentY = getInt16LE(data, 12) + var currentZ = getInt16LE(data, 14) + val deltaSizeBits = data[16].toInt() and 0xFF + val sampleCount = data[17].toInt() and 0xFF + + val samples = mutableListOf() + // The baseline sample at index 10..15 is the initial reference sample + samples.add(PolarAccSample(currentX, currentY, currentZ)) + + if (deltaSizeBits == 0 || sampleCount == 0 || deltaSizeBits > 16) { + return PolarPmdMeasurement.Acc(timestampNs, samples) } - 0x80 -> { - // Delta-compressed frame - if (data.size < 18) return null - var currentX = getInt16LE(data, 10) - var currentY = getInt16LE(data, 12) - var currentZ = getInt16LE(data, 14) - val deltaSizeBits = data[16].toInt() and 0xFF - val sampleCount = data[17].toInt() and 0xFF - - val samples = mutableListOf() - // The baseline sample at index 10..15 is the initial reference sample - samples.add(PolarAccSample(currentX, currentY, currentZ)) - if (deltaSizeBits == 0 || sampleCount == 0 || deltaSizeBits > 16) { - return PolarPmdMeasurement.Acc(timestampNs, samples) - } - - val totalAvailableBits = (data.size - 18) * 8 - var bitPos = 0 - for (i in 0 until sampleCount) { - if (bitPos + (deltaSizeBits * 3) > totalAvailableBits) break - val deltaX = readSignedBits(data, 18, bitPos, deltaSizeBits) - bitPos += deltaSizeBits - val deltaY = readSignedBits(data, 18, bitPos, deltaSizeBits) - bitPos += deltaSizeBits - val deltaZ = readSignedBits(data, 18, bitPos, deltaSizeBits) - bitPos += deltaSizeBits - - currentX += deltaX - currentY += deltaY - currentZ += deltaZ - samples.add(PolarAccSample(currentX, currentY, currentZ)) - } - - PolarPmdMeasurement.Acc( - sensorTimestampNs = timestampNs, - samples = samples, - ) + val totalAvailableBits = (data.size - 18) * 8 + var bitPos = 0 + for (i in 0 until sampleCount) { + if (bitPos + (deltaSizeBits * 3) > totalAvailableBits) break + val deltaX = readSignedBits(data, 18, bitPos, deltaSizeBits) + bitPos += deltaSizeBits + val deltaY = readSignedBits(data, 18, bitPos, deltaSizeBits) + bitPos += deltaSizeBits + val deltaZ = readSignedBits(data, 18, bitPos, deltaSizeBits) + bitPos += deltaSizeBits + + currentX += deltaX + currentY += deltaY + currentZ += deltaZ + samples.add(PolarAccSample(currentX, currentY, currentZ)) } - else -> null + + PolarPmdMeasurement.Acc( + sensorTimestampNs = timestampNs, + samples = samples, + ) } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt index ebedc4cfb..d6f35a313 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt @@ -64,37 +64,22 @@ fun PolarH10SettingsCard( val batteryLevel by polarBleManager.batteryLevel.collectAsState() val hrMeasurement by polarBleManager.hrMeasurement.collectAsState() + val latestEcg by polarBleManager.latestEcg.collectAsState() + val latestAcc by polarBleManager.latestAcc.collectAsState() + var isEcgEnabled by remember { mutableStateOf(false) } var isAccEnabled by remember { mutableStateOf(false) } - var latestEcgSampleUv by remember { mutableStateOf(null) } - var latestAccSample by remember { mutableStateOf(null) } - // Sync switch states with manager status + // Sync switch states when connection is lost LaunchedEffect(connectionState) { if (connectionState !is ConnectionState.Connected) { isEcgEnabled = false isAccEnabled = false - latestEcgSampleUv = null - latestAccSample = null - } - } - - // Collect preview samples when test toggles are active - LaunchedEffect(isEcgEnabled) { - if (isEcgEnabled) { - polarBleManager.ecgStream.collectLatest { ecg -> - latestEcgSampleUv = ecg.samplesMicrovolts.lastOrNull() - } } } - LaunchedEffect(isAccEnabled) { - if (isAccEnabled) { - polarBleManager.accStream.collectLatest { acc -> - latestAccSample = acc.samples.lastOrNull() - } - } - } + val latestEcgSampleUv = if (isEcgEnabled) latestEcg?.samplesMicrovolts?.lastOrNull() else null + val latestAccSample = if (isAccEnabled) latestAcc?.samples?.lastOrNull() else null Card( modifier = modifier diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt index 28f65852e..dd24d1bca 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt @@ -102,8 +102,9 @@ private fun PolarH10WorkoutStripContent( // Rolling ECG samples (µV) val ecgSamples = remember { mutableStateListOf() } - // Latest accelerometer sample - var latestAccSample by remember { mutableStateOf(null) } + // Latest accelerometer sample from dedicated StateFlow + val latestAcc by polarBleManager.latestAcc.collectAsState() + val latestAccSample = latestAcc?.samples?.lastOrNull() // 1. Auto-start ECG & ACC on enter, cleanly stop on dispose LaunchedEffect(Unit) { @@ -135,7 +136,7 @@ private fun PolarH10WorkoutStripContent( } LaunchedEffect(Unit) { - polarBleManager.ecgStream.collectLatest { ecg -> + polarBleManager.ecgStream.collect { ecg -> ecgSamples.addAll(ecg.samplesMicrovolts) while (ecgSamples.size > MAX_ECG_DISPLAY_SAMPLES) { ecgSamples.removeAt(0) @@ -143,12 +144,6 @@ private fun PolarH10WorkoutStripContent( } } - LaunchedEffect(Unit) { - polarBleManager.accStream.collectLatest { acc -> - latestAccSample = acc.samples.lastOrNull() - } - } - Surface( modifier = modifier .fillMaxWidth() 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 bae721320..d3ddb8377 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt @@ -197,12 +197,16 @@ object BleConstants { 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 val PMD_START_ACC_CMD: ByteArray get() = Commands.START_ACC + val PMD_START_ACC_ASCENDING_CMD: ByteArray get() = Commands.START_ACC_ASCENDING + val PMD_START_ACC_FALLBACK_25HZ_CMD: ByteArray get() = Commands.START_ACC_FALLBACK_25HZ val PMD_STOP_ECG_CMD: ByteArray get() = Commands.STOP_ECG val PMD_STOP_ACC_CMD: ByteArray get() = Commands.STOP_ACC } 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 index d634ee599..97b33746e 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt @@ -75,6 +75,8 @@ class PolarH10BleManagerTest { assertNull(manager.batteryLevel.value) assertNull(manager.hrMeasurement.value) assertNull(manager.rrClockTimestampMs.value) + assertNull(manager.latestEcg.value) + assertNull(manager.latestAcc.value) assertFalse(manager.isEcgActive) assertFalse(manager.isAccActive) } 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 index a5684f0b6..eec64caf4 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParserTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParserTest.kt @@ -313,4 +313,75 @@ class PolarProtocolParserTest { assertEquals(1, acc.samples.size) assertEquals(PolarAccSample(50, 50, 50), acc.samples[0]) } + + @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]) + } } From aeda042efb2a77ea70dc2a56f3259a92f71b8d4b Mon Sep 17 00:00:00 2001 From: StuGotz Date: Tue, 20 Oct 2026 06:56:54 -0500 Subject: [PATCH 05/25] feat(polar): add migration 58, 3D torso biomechanics, auto-reconnect, and unconditional backup support --- .../data/local/SchemaParityTest.kt | 26 ++ .../util/DataBackupCompletenessTest.kt | 114 +++++++ .../di/PlatformModule.android.kt | 1 + .../data/ble/PolarH10BleManager.kt | 320 +++++++++++++++++- .../data/local/MigrationStatements.kt | 47 ++- .../data/local/SchemaManifest.kt | 84 ++++- .../repository/SqlDelightSyncRepository.kt | 18 + .../repository/SqlDelightWorkoutRepository.kt | 87 ++++- .../com/devil/phoenixproject/di/DataModule.kt | 2 +- .../phoenixproject/domain/model/Models.kt | 19 ++ .../components/PolarH10SettingsCard.kt | 190 ++++++++++- .../manager/ActiveSessionEngine.kt | 35 +- .../manager/DefaultWorkoutSessionManager.kt | 29 ++ .../presentation/viewmodel/MainViewModel.kt | 3 + .../devil/phoenixproject/util/BackupModels.kt | 62 +++- .../phoenixproject/util/DataBackupManager.kt | 270 +++++++++++++-- .../util/DeletedProfileBackupFilter.kt | 2 + .../util/PolarTelemetryUtils.kt | 171 ++++++++++ .../database/PhoenixDatabase.sq | 129 ++++++- .../phoenixproject/database/migrations/58.sqm | 54 +++ .../util/BackupSerializationTest.kt | 120 +++++++ .../util/PolarTelemetryUtilsTest.kt | 122 +++++++ .../phoenixproject/di/PlatformModule.ios.kt | 1 + .../screen/ActiveWorkoutScreen.kt | 12 +- 24 files changed, 1870 insertions(+), 48 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt create mode 100644 shared/src/commonTest/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtilsTest.kt 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..ce09dae7c 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,6 +1302,32 @@ class SchemaParityTest { } } + @Test + fun `migration 58 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, 58) + assertEquals(false, getTables(driver).contains("PolarBeatSample"), scenario) + assertEquals(false, getTables(driver).contains("PolarEcgSegment"), scenario) + + if (fallback) { + val results = applyMigrationResilient(driver, 58) + assertEquals(true, results.all { it.success }, scenario) + } else { + PhoenixDatabase.Schema.migrate(driver, 58, 59) + } + + 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 { 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..c3496c8ce 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,120 @@ class DataBackupCompletenessTest { assertEquals(150.0, target.queries.selectExerciseById("stock-press").executeAsOne().mvtOverrideMs, "local override wins") } + // ---- polar telemetry completeness ---- + + @Test + fun `polar summaries samples and ecg traces survive backup and restore even when raw telemetry is false`() = 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, + ) + // 1 normal sample (should be excluded because includeRawTelemetry = false and no polar fields) + 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, + ) + // 1 sample WITH Polar data (must be included even when includeRawTelemetry = false) + 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(1, backupData.data.polarBeatSamples.size) + assertEquals(1, backupData.data.polarEcgSegments.size) + // Metric samples: normal omitted, polar included + assertEquals(1, backupData.data.metricSamples.size) + assertEquals(145, backupData.data.metricSamples[0].heartRateBpm) + + val target = Fixture(includeRawTelemetry = false) + val result = target.manager.importFromJson(exportedJson).getOrThrow() + assertEquals(0, result.entitiesWithErrors) + assertEquals(1, result.polarBeatSamplesImported) + assertEquals(1, result.polarEcgSegmentsImported) + + // Verify target database + 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(1, targetMetrics.size) + assertEquals(145L, targetMetrics[0].heartRateBpm) + assertEquals(100L, targetMetrics[0].chestAccXMg) + + val targetBeats = target.queries.selectPolarBeatSamplesBySession("session-polar-1").executeAsList() + assertEquals(1, targetBeats.size) + assertEquals(740.0, targetBeats[0].rrMs) + assertEquals(145L, targetBeats[0].hrBpm) + + val targetEcgs = target.queries.selectPolarEcgSegmentsBySession("session-polar-1").executeAsList() + assertEquals(1, targetEcgs.size) + assertEquals("100,105,110", targetEcgs[0].samplesUvCsv) + } + // ---- 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 index 852955b23..584ae2265 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -2,25 +2,33 @@ package com.devil.phoenixproject.data.ble import co.touchlab.kermit.Logger import com.devil.phoenixproject.data.repository.ScannedDevice +import com.devil.phoenixproject.database.PhoenixDatabase import com.devil.phoenixproject.domain.model.ConnectionState 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.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.math.abs +import kotlin.math.roundToInt import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -30,12 +38,27 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.filter 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.withTimeout import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.coroutines.delay + +/** + * Summary metrics for a session recorded by Polar H10. + */ +data class PolarSessionSummary( + 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. @@ -48,11 +71,183 @@ import kotlinx.coroutines.delay * without cross-peripheral interference. */ class PolarH10BleManager( + private val settings: Settings? = null, + private val database: PhoenixDatabase? = null, private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), private val timeSource: () -> Long = ::currentTimeMillis, ) { private val log = Logger.withTag("PolarH10BleManager") + companion object { + 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" + } + + // ===== 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 + } + + fun forgetRememberedDevice() { + settings?.remove(KEY_POLAR_REMEMBERED_ADDRESS) + settings?.remove(KEY_POLAR_REMEMBERED_NAME) + _rememberedAddress.value = null + _rememberedName.value = 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 ===== + private var activeSessionId: 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 var maxTorsoPitch: Double? = null + private var maxTorsoRoll: Double? = null + private var endHeartRate: Int? = null + private var hrr60: Int? = null + private var hrr300: Int? = null + + val currentRecordingSessionId: String? get() = activeSessionId + + fun startSessionRecording(sessionId: String, phase: String = "WARMUP") { + cooldownJob?.cancel() + cooldownJob = null + activeSessionId = sessionId + currentPhase = phase + sessionHrs.clear() + sessionRrs.clear() + recentRrs.clear() + maxTorsoPitch = null + maxTorsoRoll = null + endHeartRate = null + hrr60 = null + hrr300 = null + + if (_connectionState.value is ConnectionState.Connected) { + if (!isAccStreaming) scope.launch { startAccStream() } + if (!isEcgStreaming) scope.launch { startEcgStream() } + } + } + + fun updateSessionPhase(phase: String) { + currentPhase = phase + } + + fun endSessionRecording(sessionId: String? = null) { + val targetSessionId = sessionId ?: activeSessionId ?: return + if (activeSessionId == null) { + activeSessionId = targetSessionId + } + endHeartRate = _hrMeasurement.value?.hr ?: sessionHrs.lastOrNull() + currentPhase = "POST_WORKOUT_COOLDOWN" + + cooldownJob?.cancel() + cooldownJob = scope.launch { + val cooldownStartMs = timeSource() + var hrr60Persisted = false + var hrr300Persisted = false + while (isActive && (timeSource() - cooldownStartMs) < 300_000L) { + delay(1000L) + val elapsedMs = timeSource() - cooldownStartMs + val curHr = _hrMeasurement.value?.hr + val endHr = endHeartRate + if (elapsedMs >= 60_000L && !hrr60Persisted && curHr != null && endHr != null) { + hrr60 = (endHr - curHr).coerceAtLeast(0) + hrr60Persisted = true + persistPolarSummary(targetSessionId) + } + if (elapsedMs >= 300_000L && !hrr300Persisted && curHr != null && endHr != null) { + hrr300 = (endHr - curHr).coerceAtLeast(0) + hrr300Persisted = true + persistPolarSummary(targetSessionId) + } + } + val finalHr = _hrMeasurement.value?.hr + if (!hrr300Persisted && finalHr != null && endHeartRate != null) { + hrr300 = (endHeartRate!! - finalHr).coerceAtLeast(0) + persistPolarSummary(targetSessionId) + } + if (activeSessionId == targetSessionId) { + activeSessionId = null + } + } + } + + fun getSessionSummary(sessionId: String): PolarSessionSummary? { + if (activeSessionId != null && activeSessionId != sessionId) return null + val avgHr = if (sessionHrs.isNotEmpty()) sessionHrs.average().roundToInt() else null + val maxHr = sessionHrs.maxOrNull() + val minHr = sessionHrs.minOrNull() + val avgRr = if (sessionRrs.isNotEmpty()) sessionRrs.average().toFloat() else null + val rmssd = PolarTelemetryUtils.calculateRmssdMs(sessionRrs)?.toFloat() + return PolarSessionSummary( + avgHeartRateBpm = avgHr, + maxHeartRateBpm = maxHr, + minHeartRateBpm = minHr, + avgRrIntervalMs = avgRr, + rmssdMs = rmssd, + hrr60Bpm = hrr60, + hrr300Bpm = hrr300, + maxTorsoPitchDeg = maxTorsoPitch?.toFloat(), + maxTorsoRollDeg = maxTorsoRoll?.toFloat(), + ) + } + + private fun persistPolarSummary(sessionId: String) { + val summary = getSessionSummary(sessionId) ?: return + try { + database?.phoenixDatabaseQueries?.updateSessionPolarSummary( + id = sessionId, + avgHeartRateBpm = summary.avgHeartRateBpm?.toLong(), + maxHeartRateBpm = summary.maxHeartRateBpm?.toLong(), + minHeartRateBpm = summary.minHeartRateBpm?.toLong(), + avgRrIntervalMs = summary.avgRrIntervalMs?.toDouble(), + rmssdMs = summary.rmssdMs?.toDouble(), + hrr60Bpm = summary.hrr60Bpm?.toLong(), + hrr300Bpm = summary.hrr300Bpm?.toLong(), + maxTorsoPitchDeg = summary.maxTorsoPitchDeg?.toDouble(), + maxTorsoRollDeg = summary.maxTorsoRollDeg?.toDouble(), + ) + } catch (e: Exception) { + log.w { "Failed to update session polar summary: ${e.message}" } + } + } + // ===== Connection & Discovery State ===== private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) val connectionState: StateFlow = _connectionState.asStateFlow() @@ -210,6 +405,62 @@ class PolarH10BleManager( } } + private class AutoReconnectMatchFound(val device: ScannedDevice) : CancellationException() + + /** + * 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) + } + 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 = discoveredAdvertisements[address] + ?: discoveredAdvertisements.values.firstOrNull { it.identifier.toString() == address } + if (cachedAdv != null) { + return connect(ScannedDevice(name = name, address = address)) + } + + return try { + val matchedDevice = withTimeoutOrNull(timeoutMs) { + var found: ScannedDevice? = null + try { + Scanner {} + .advertisements + .filter { isPolarH10Advertisement(it) } + .collect { adv -> + val id = adv.identifier.toString() + 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 { + 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. */ @@ -240,6 +491,12 @@ class PolarH10BleManager( deviceName = device.name, deviceAddress = device.address, ) + 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 -> { @@ -373,6 +630,30 @@ class PolarH10BleManager( hr = measurement.hr, ) _rrBeats.tryEmit(beat) + + val currentSid = activeSessionId + if (currentSid != null) { + sessionHrs.add(beat.hr) + sessionRrs.add(beat.rrIntervalMs) + recentRrs.addLast(beat.rrIntervalMs) + if (recentRrs.size > 30) recentRrs.removeFirst() + val currentRmssd = PolarTelemetryUtils.calculateRmssdMs(recentRrs.toList()) + try { + database?.phoenixDatabaseQueries?.insertPolarBeatSample( + sessionId = currentSid, + timestampMs = beat.timestampMs, + phase = currentPhase, + hrBpm = beat.hr.toLong(), + rrMs = beat.rrIntervalMs, + rmssdMs = currentRmssd, + torsoPitchDeg = _torsoPitchDeg.value, + torsoRollDeg = _torsoRollDeg.value, + dynamicAccMg = _dynamicAccMg.value?.toLong(), + ) + } catch (e: Exception) { + log.w { "Failed to insert PolarBeatSample: ${e.message}" } + } + } } lastBeatTimestampMs = runningTs @@ -432,10 +713,40 @@ class PolarH10BleManager( is PolarPmdMeasurement.Ecg -> { _latestEcg.value = measurement _ecgStream.tryEmit(measurement) + val currentSid = activeSessionId + if (currentSid != null && database != null) { + try { + val nowMs = timeSource() + val csv = measurement.samplesMicrovolts.joinToString(",") + database.phoenixDatabaseQueries.insertPolarEcgSegment( + sessionId = currentSid, + sensorTimestampNs = measurement.sensorTimestampNs, + wallClockTimestampMs = nowMs, + phase = currentPhase, + samplesUvCsv = csv, + ) + } catch (e: Exception) { + log.w { "Failed to insert PolarEcgSegment: ${e.message}" } + } + } } is PolarPmdMeasurement.Acc -> { _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) { + if (maxTorsoPitch == null || bio.pitchDeg > maxTorsoPitch!!) { + maxTorsoPitch = bio.pitchDeg + } + if (maxTorsoRoll == null || abs(bio.rollDeg) > abs(maxTorsoRoll!!)) { + maxTorsoRoll = bio.rollDeg + } + } + } } } } @@ -665,6 +976,13 @@ class PolarH10BleManager( _rrClockTimestampMs.value = null _latestEcg.value = null _latestAcc.value = null + + cooldownJob?.cancel() + cooldownJob = null + accFilter.reset() + _torsoPitchDeg.value = null + _torsoRollDeg.value = null + _dynamicAccMg.value = null } private fun cleanupExistingConnection() { 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..37b4a8f9a 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 @@ -1,4 +1,4 @@ -package com.devil.phoenixproject.data.local +package com.devil.phoenixproject.data.local import app.cash.sqldelight.db.SqlDriver @@ -1308,7 +1308,52 @@ WHERE gs.rowid = ( "ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_local_generation INTEGER NOT NULL DEFAULT 0", "ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_server_revision INTEGER NOT NULL DEFAULT 0", "ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_dirty INTEGER NOT NULL DEFAULT 0", + + // Migration 58: Polar H10 multi-tier telemetry persistence. Mirrors 58.sqm exactly. + 58 -> 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..9dba45c0b 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 58) 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 58 + 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 58 + 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 58: 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 58: 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 58) ────────────────────────────────── + SchemaIndexOperation("idx_polar_beat_sample_session_ts", "CREATE INDEX IF NOT EXISTS idx_polar_beat_sample_session_ts ON PolarBeatSample(sessionId, timestampMs)"), + + // ── PolarEcgSegment (migration 58) ────────────────────────────────── + 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/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..8652324ec 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 @@ -122,6 +122,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 +189,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 +651,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) { 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 9c8a2ec4c..99974fd4e 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt @@ -172,5 +172,5 @@ val dataModule = module { single { HealthBackfillManager(get(), get(), get(), get()) } // Polar H10 BLE Manager - single { PolarH10BleManager() } onClose { (it as? PolarH10BleManager)?.shutdown() } + single { PolarH10BleManager(settings = get(), database = 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..1c46e24c7 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 58) + 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 58) + 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/presentation/components/PolarH10SettingsCard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt index d6f35a313..4cc71061c 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt @@ -52,6 +52,7 @@ import com.devil.phoenixproject.presentation.util.formatRrMs import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.compose.koinInject +import kotlin.math.roundToInt @Composable fun PolarH10SettingsCard( @@ -67,6 +68,14 @@ fun PolarH10SettingsCard( val latestEcg by polarBleManager.latestEcg.collectAsState() val latestAcc by polarBleManager.latestAcc.collectAsState() + val rememberedAddress by polarBleManager.rememberedAddress.collectAsState() + val rememberedName by polarBleManager.rememberedName.collectAsState() + val isAutoReconnectEnabled by polarBleManager.isAutoReconnectEnabled.collectAsState() + + val torsoPitchDeg by polarBleManager.torsoPitchDeg.collectAsState() + val torsoRollDeg by polarBleManager.torsoRollDeg.collectAsState() + val dynamicAccMg by polarBleManager.dynamicAccMg.collectAsState() + var isEcgEnabled by remember { mutableStateOf(false) } var isAccEnabled by remember { mutableStateOf(false) } @@ -169,13 +178,87 @@ fun PolarH10SettingsCard( 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") + } + } + } + + 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( + "Automatically connects when entering active workout", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = isAutoReconnectEnabled, + onCheckedChange = { polarBleManager.setAutoReconnectEnabled(it) }, + ) + } + } + 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("Scan for Polar H10") + Text(if (rememberedAddress != null) "Scan for Other Sensors" else "Scan for Polar H10") } } @@ -321,6 +404,111 @@ fun PolarH10SettingsCard( 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) }, + ) + } + + if (rememberedAddress != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton( + onClick = { polarBleManager.forgetRememberedDevice() }, + ) { + Text("Forget Sensor", color = MaterialTheme.colorScheme.error) + } + } + } + } + + // 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") + } + } } Spacer(modifier = Modifier.height(Spacing.small)) 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..653b22621 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,24 @@ 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 -> + 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 +6686,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 +8095,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 +9303,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?.getSessionSummary(lease.sessionId) val session = WorkoutSession( id = lease.sessionId, timestamp = timing.startMs, @@ -9329,6 +9351,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 && 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..d498e191a 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,32 @@ 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 -> { + polarBleManager.endSessionRecording() + } + else -> {} + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Logger.e(e) { "Error in Polar session phase collector" } + } + } + } + scope.launch { try { settingsManager.userPreferences 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..b29f0db6e 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 @@ -543,6 +544,7 @@ class MainViewModel( internal val machineSafetyCoordinator: MachineSafetyCoordinator, private val profileRecoveryActivityTracker: ProfileRecoveryActivityTracker? = null, private val recentJustLiftExerciseStore: RecentJustLiftExerciseStore? = null, + private 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) === 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..b85840965 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 58) + 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 58) + 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, ) /** @@ -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/DataBackupManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt index 2353c3cdb..4bdb5de24 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,12 +444,16 @@ 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() - if (includeRawTelemetryInBackups) { - for (session in sessions) { - val sessionMetrics = queries.selectMetricsBySession(session.id).executeAsList() + for (session in sessions) { + val sessionMetrics = queries.selectMetricsBySession(session.id).executeAsList() + if (includeRawTelemetryInBackups) { metrics.addAll(sessionMetrics) + } else { + metrics.addAll(sessionMetrics.filter { it.heartRateBpm != null || it.chestAccXMg != null }) } } + val polarBeatSamples = queries.selectAllBackupPolarBeatSamples().executeAsList() + val polarEcgSegments = queries.selectAllBackupPolarEcgSegments().executeAsList() // Full backups retain routine tombstones because historical telemetry keeps // foreign-key links to the original routine and routine exercise IDs. @@ -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,76 @@ 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 { + 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(), + ) + queries.markWorkoutComponentDirty(beat.sessionId) + holdGroupOfRestoredChild(beat.sessionId) + polarBeatSamplesImported++ + } + } + 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 { + queries.insertPolarEcgSegmentRestoreIfAbsent( + id = segment.id, + sessionId = segment.sessionId, + sensorTimestampNs = segment.sensorTimestampNs, + wallClockTimestampMs = segment.wallClockTimestampMs, + phase = segment.phase, + samplesUvCsv = segment.samplesUvCsv, + ) + queries.markWorkoutComponentDirty(segment.sessionId) + holdGroupOfRestoredChild(segment.sessionId) + polarEcgSegmentsImported++ + } + } + nav.endArray() + } + } + // --- progressionEvents --- "progressionEvents" -> { committedTransaction { @@ -2878,6 +3028,10 @@ abstract class BaseDataBackupManager( cycleSyncStatesSkipped = cycleSyncStatesSkipped, cycleConflictDraftsImported = cycleConflictDraftsImported, cycleConflictDraftsSkipped = cycleConflictDraftsSkipped, + polarBeatSamplesImported = polarBeatSamplesImported, + polarBeatSamplesSkipped = polarBeatSamplesSkipped, + polarEcgSegmentsImported = polarEcgSegmentsImported, + polarEcgSegmentsSkipped = polarEcgSegmentsSkipped, repairedReferences = repairedReferences, entitiesWithErrors = entitiesWithErrors, ) @@ -3001,16 +3155,21 @@ 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()) { + // Raw telemetry is opt-in (F-033); but metrics with Polar data are always included. + for (session in sessions) { val sessionMetrics = queries.selectMetricsBySession(session.id).executeAsList() - for (metric in sessionMetrics) { + val filteredMetrics = if (includeRawTelemetryInBackups) { + sessionMetrics + } else { + sessionMetrics.filter { it.heartRateBpm != null || it.chestAccXMg != null } + } + for (metric in filteredMetrics) { if (!firstMetric) writer.write(",") firstMetric = false writer.write(json.encodeToString(MetricSampleBackup.serializer(), mapMetricToBackup(metric))) metricIndex++ } - if (sessionMetrics.isNotEmpty()) { + if (filteredMetrics.isNotEmpty()) { writer.flush() onProgress(BackupProgress(BackupPhase.METRICS, metricIndex, metricCount)) } @@ -3075,6 +3234,14 @@ abstract class BaseDataBackupManager( }) writer.write(",") + val polarBeatSamples = queries.selectAllBackupPolarBeatSamples().executeAsList() + writeJsonArray(writer, "polarBeatSamples", polarBeatSamples.map { json.encodeToString(PolarBeatSampleBackup.serializer(), mapPolarBeatSampleToBackup(it)) }) + writer.write(",") + + val polarEcgSegments = queries.selectAllBackupPolarEcgSegments().executeAsList() + writeJsonArray(writer, "polarEcgSegments", polarEcgSegments.map { json.encodeToString(PolarEcgSegmentBackup.serializer(), mapPolarEcgSegmentToBackup(it)) }) + writer.write(",") + val progressionEvents = queries.selectAllProgressionEventsSync().executeAsList() writeJsonArray(writer, "progressionEvents", progressionEvents.map { json.encodeToString(ProgressionEventBackup.serializer(), mapProgressionEventToBackup(it)) }) writer.write(",") @@ -3366,6 +3533,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 +3557,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 +4296,14 @@ 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 sessionMetrics = queries.selectMetricsBySession(sessionId).executeAsList() + val metrics = if (includeRawTelemetryInBackups) { + sessionMetrics + } else { + sessionMetrics.filter { it.heartRateBpm != null || it.chestAccXMg != null } + } + val beatSamples = queries.selectPolarBeatSamplesBySession(sessionId).executeAsList() + val ecgSegments = queries.selectPolarEcgSegmentsBySession(sessionId).executeAsList() val completedSets = queries.selectCompletedSetsBySession(sessionId).executeAsList() val sessions = listOf(session) @@ -4111,6 +4324,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,9 +4380,18 @@ 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()) + val sessionMetrics = queries.selectMetricsBySession(session.id).executeAsList() + if (includeRawTelemetryInBackups) { + allMetrics.addAll(sessionMetrics) + } else { + allMetrics.addAll(sessionMetrics.filter { it.heartRateBpm != null || it.chestAccXMg != null }) + } allCompletedSets.addAll(queries.selectCompletedSetsBySession(session.id).executeAsList()) + allBeatSamples.addAll(queries.selectPolarBeatSamplesBySession(session.id).executeAsList()) + allEcgSegments.addAll(queries.selectPolarEcgSegmentsBySession(session.id).executeAsList()) } val routineNowMs = KmpUtils.currentTimeMillis() @@ -4183,6 +4407,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): Double? { + if (rrs.size < 2) return null + var sumSquares = 0.0 + for (i in 1 until rrs.size) { + val diff = rrs[i] - rrs[i - 1] + sumSquares += diff * diff + } + return sqrt(sumSquares / (rrs.size - 1)) + } +} + +/** + * 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..3b5d81fdc 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 58) + 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 58) + 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 58) +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 58) +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,93 @@ 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 58) +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; + +insertPolarBeatSample: +INSERT INTO PolarBeatSample ( + sessionId, timestampMs, phase, hrBpm, rrMs, rmssdMs, + torsoPitchDeg, torsoRollDeg, dynamicAccMg +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + +selectPolarBeatSamplesBySession: +SELECT * FROM PolarBeatSample WHERE sessionId = ? ORDER BY timestampMs ASC; + +deletePolarBeatSamplesBySession: +DELETE FROM PolarBeatSample WHERE sessionId = ?; + +countBackupPolarBeatSamples: +SELECT COUNT(*) FROM PolarBeatSample WHERE sessionId IN (SELECT id FROM WorkoutSession WHERE deletedAt IS NULL); + +selectAllBackupPolarBeatSamples: +SELECT * FROM PolarBeatSample WHERE sessionId IN (SELECT id FROM WorkoutSession WHERE deletedAt IS NULL) ORDER BY timestampMs ASC; + +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; + +deletePolarEcgSegmentsBySession: +DELETE FROM PolarEcgSegment WHERE sessionId = ?; + +countBackupPolarEcgSegments: +SELECT COUNT(*) FROM PolarEcgSegment WHERE sessionId IN (SELECT id FROM WorkoutSession WHERE deletedAt IS NULL); + +selectAllBackupPolarEcgSegments: +SELECT * FROM PolarEcgSegment WHERE sessionId IN (SELECT id FROM WorkoutSession WHERE deletedAt IS NULL) ORDER BY wallClockTimestampMs ASC; + +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/58.sqm b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/58.sqm index 4d2a24f1a..22da829ab 100644 --- a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/58.sqm +++ b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/58.sqm @@ -14,3 +14,57 @@ ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_local_generation ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_server_revision INTEGER NOT NULL DEFAULT 0; ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_dirty INTEGER NOT NULL DEFAULT 0; + +-- Migration 58: 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/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..9d352f86f --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtilsTest.kt @@ -0,0 +1,122 @@ +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.0 + val y = 0.0 + val z = 0.0 + + val metrics = PolarTelemetryUtils.computeTorsoBiomechanics(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 splits between -X (707 mG) and +Z (707 mG) + val x = -707.106 + val y = 0.0 + val z = 707.106 + + val metrics = PolarTelemetryUtils.computeTorsoBiomechanics(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 horizontalHingeProducesNinetyDegreesPitch() { + // 90° horizontal bend (torso parallel to ground): gravity entirely along +Z + val x = 0.0 + val y = 0.0 + val z = 1000.0 + + val metrics = PolarTelemetryUtils.computeTorsoBiomechanics(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.025 + val y = 500.0 + val z = 0.0 + + val metrics = PolarTelemetryUtils.computeTorsoBiomechanics(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 + + 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) + } +} 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/screen/ActiveWorkoutScreen.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/ActiveWorkoutScreen.kt index 928e41a93..38f8a98a9 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,6 +35,7 @@ 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 @@ -87,7 +88,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() @@ -189,6 +195,10 @@ fun ActiveWorkoutScreen(navController: NavController, viewModel: MainViewModel, } } + LaunchedEffect(Unit) { + polarBleManager.autoReconnectIfRemembered() + } + LaunchedEffect(Unit) { viewModel.userFeedbackEvents.collect { message -> snackbarScope.launch { From fe00387d91755026756a1d496dc165d8796bf341 Mon Sep 17 00:00:00 2001 From: StuGotz Date: Tue, 20 Oct 2026 07:27:25 -0500 Subject: [PATCH 06/25] feat(polar): add 5-minute post-workout HRR cooldown and set summary analytics card --- .../data/ble/PolarH10BleManager.kt | 81 ++- .../components/PolarSessionAnalyticsCard.kt | 566 ++++++++++++++++++ .../data/ble/PolarH10BleManagerTest.kt | 42 ++ .../screen/ActiveWorkoutScreen.kt | 16 +- .../screen/RoutineCompleteScreen.kt | 6 + .../presentation/screen/SetSummaryCard.kt | 19 +- .../presentation/screen/WorkoutTab.kt | 4 + 7 files changed, 724 insertions(+), 10 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt 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 index 584ae2265..a55953f51 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -142,11 +142,38 @@ class PolarH10BleManager( private var hrr60: Int? = null private var hrr300: Int? = null + 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 _latestSessionSummary = MutableStateFlow(null) + val latestSessionSummary: StateFlow = _latestSessionSummary.asStateFlow() + val currentRecordingSessionId: String? get() = activeSessionId fun startSessionRecording(sessionId: String, phase: String = "WARMUP") { cooldownJob?.cancel() cooldownJob = null + _isCooldownActive.value = false + _cooldownSecondsRemaining.value = 0 + _cooldownHrr60.value = null + _cooldownHrr300.value = null + _cooldownStartHeartRate.value = null + _activeCooldownSessionId.value = null activeSessionId = sessionId currentPhase = phase sessionHrs.clear() @@ -157,6 +184,7 @@ class PolarH10BleManager( endHeartRate = null hrr60 = null hrr300 = null + _latestSessionSummary.value = null if (_connectionState.value is ConnectionState.Connected) { if (!isAccStreaming) scope.launch { startAccStream() } @@ -168,13 +196,47 @@ class PolarH10BleManager( currentPhase = phase } + fun triggerCooldown(sessionId: String? = null) { + endSessionRecording(sessionId) + } + + fun stopCooldown() { + val targetSessionId = activeSessionId ?: _activeCooldownSessionId.value + cooldownJob?.cancel() + cooldownJob = null + val finalHr = _hrMeasurement.value?.hr + val endHr = endHeartRate ?: _cooldownStartHeartRate.value + if (finalHr != null && endHr != null) { + val drop = (endHr - finalHr).coerceAtLeast(0) + if (hrr60 == null) { + hrr60 = drop + _cooldownHrr60.value = drop + } + if (hrr300 == null) { + hrr300 = drop + _cooldownHrr300.value = drop + } + } + _isCooldownActive.value = false + _cooldownSecondsRemaining.value = 0 + if (targetSessionId != null) { + persistPolarSummary(targetSessionId) + _latestSessionSummary.value = getSessionSummary(targetSessionId) + } + } + fun endSessionRecording(sessionId: String? = null) { val targetSessionId = sessionId ?: activeSessionId ?: return if (activeSessionId == null) { activeSessionId = targetSessionId } + _activeCooldownSessionId.value = targetSessionId endHeartRate = _hrMeasurement.value?.hr ?: sessionHrs.lastOrNull() + _cooldownStartHeartRate.value = endHeartRate currentPhase = "POST_WORKOUT_COOLDOWN" + _isCooldownActive.value = true + _cooldownSecondsRemaining.value = 300 + _latestSessionSummary.value = getSessionSummary(targetSessionId) cooldownJob?.cancel() cooldownJob = scope.launch { @@ -184,24 +246,37 @@ class PolarH10BleManager( while (isActive && (timeSource() - cooldownStartMs) < 300_000L) { delay(1000L) val elapsedMs = timeSource() - cooldownStartMs + val remainingSec = (300 - (elapsedMs / 1000)).toInt().coerceAtLeast(0) + _cooldownSecondsRemaining.value = remainingSec + val curHr = _hrMeasurement.value?.hr val endHr = endHeartRate if (elapsedMs >= 60_000L && !hrr60Persisted && curHr != null && endHr != null) { - hrr60 = (endHr - curHr).coerceAtLeast(0) + val drop = (endHr - curHr).coerceAtLeast(0) + hrr60 = drop + _cooldownHrr60.value = drop hrr60Persisted = true persistPolarSummary(targetSessionId) } if (elapsedMs >= 300_000L && !hrr300Persisted && curHr != null && endHr != null) { - hrr300 = (endHr - curHr).coerceAtLeast(0) + val drop = (endHr - curHr).coerceAtLeast(0) + hrr300 = drop + _cooldownHrr300.value = drop hrr300Persisted = true persistPolarSummary(targetSessionId) } + _latestSessionSummary.value = getSessionSummary(targetSessionId) } val finalHr = _hrMeasurement.value?.hr if (!hrr300Persisted && finalHr != null && endHeartRate != null) { - hrr300 = (endHeartRate!! - finalHr).coerceAtLeast(0) + val drop = (endHeartRate!! - finalHr).coerceAtLeast(0) + hrr300 = drop + _cooldownHrr300.value = drop persistPolarSummary(targetSessionId) } + _isCooldownActive.value = false + _cooldownSecondsRemaining.value = 0 + _latestSessionSummary.value = getSessionSummary(targetSessionId) if (activeSessionId == targetSessionId) { activeSessionId = null } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt new file mode 100644 index 000000000..7a51bf0ff --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt @@ -0,0 +1,566 @@ +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.background +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +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.ui.theme.Spacing +import org.koin.compose.koinInject +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), and the 5-minute + * Heart Rate Recovery (HRR) cooldown protocol with real-time countdown, drop tracking, and control. + */ +@Composable +fun PolarSessionAnalyticsCard( + sessionId: String? = null, + modifier: Modifier = Modifier, + polarBleManager: PolarH10BleManager = koinInject(), +) { + val connectionState by polarBleManager.connectionState.collectAsState() + val isCooldownActive by polarBleManager.isCooldownActive.collectAsState() + val cooldownSecondsRemaining by polarBleManager.cooldownSecondsRemaining.collectAsState() + val cooldownHrr60 by polarBleManager.cooldownHrr60.collectAsState() + val cooldownHrr300 by polarBleManager.cooldownHrr300.collectAsState() + val cooldownStartHr by polarBleManager.cooldownStartHeartRate.collectAsState() + val latestSummary by polarBleManager.latestSessionSummary.collectAsState() + val hrMeasurement by polarBleManager.hrMeasurement.collectAsState() + + val summary = latestSummary ?: remember(sessionId) { + sessionId?.let { polarBleManager.getSessionSummary(it) } + } + + val isConnected = connectionState is ConnectionState.Connected + val hasCardiacData = summary?.avgHeartRateBpm != null || summary?.maxHeartRateBpm != null || isCooldownActive + val hasBiomechanicsData = summary?.maxTorsoPitchDeg != null || summary?.maxTorsoRollDeg != null + + // If no Polar data and not connected or in cooldown, hide the card + if (!hasCardiacData && !hasBiomechanicsData && !isConnected && !isCooldownActive) { + return + } + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + 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( + 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 = Color.White, + 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, + ) + Text( + text = if (isCooldownActive) "Recovery Cooldown Active" else "Cardiac & Biomechanics Summary", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + + Surface( + shape = RoundedCornerShape(8.dp), + 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 + }, + 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 = if (rmssd >= 50f) "High Vagal Tone" else if (rmssd >= 25f) "Moderate" else "High Strain", + 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 + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + 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 + val formattedTime = "${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}" + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.primary, + ) { + Text( + text = formattedTime, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + ) + } + } + } + + if (isCooldownActive) { + val progress = (300 - cooldownSecondsRemaining) / 300f + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(3.dp)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + + val currentHr = hrMeasurement?.hr + val startHr = cooldownStartHr + if (currentHr != null && startHr != null) { + val drop = (startHr - currentHr).coerceAtLeast(0) + Text( + text = "Peak: $startHr bpm ➔ Current: $currentHr bpm (Drop: -$drop bpm)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // 1-min HRR status + if (cooldownHrr60 != null) { + Surface( + shape = RoundedCornerShape(8.dp), + color = Color(0xFF4CAF50).copy(alpha = 0.15f), + modifier = Modifier.weight(1f), + ) { + Row( + modifier = Modifier.padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = Color(0xFF4CAF50), + modifier = Modifier.size(16.dp), + ) + Text( + text = "1m HRR: -${cooldownHrr60} bpm", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color(0xFF4CAF50), + ) + } + } + } else { + val secTo60 = (60 - (300 - cooldownSecondsRemaining)).coerceAtLeast(0) + Text( + text = "Measuring 1m HRR in ${secTo60}s...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + } + + OutlinedButton( + onClick = { polarBleManager.stopCooldown() }, + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + ) { + Text( + "Finish Cooldown", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + } + } + } else { + // Cooldown is NOT active + val recordedHrr60 = cooldownHrr60 ?: summary?.hrr60Bpm + val recordedHrr300 = cooldownHrr300 ?: summary?.hrr300Bpm + + if (recordedHrr60 != null || recordedHrr300 != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + recordedHrr60?.let { hrr -> + val rating = if (hrr >= 25) "Excellent" else if (hrr >= 15) "Good" else "Moderate" + RecoveryChip( + label = "1-Min Recovery", + drop = hrr, + rating = rating, + modifier = Modifier.weight(1f), + ) + } + recordedHrr300?.let { hrr -> + val rating = if (hrr >= 45) "Excellent" else if (hrr >= 30) "Good" else "Moderate" + RecoveryChip( + label = "5-Min Recovery", + drop = hrr, + rating = rating, + modifier = Modifier.weight(1f), + ) + } + } + } else if (isConnected) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Track your autonomic cardiac recovery for 5 minutes.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f).padding(end = 8.dp), + ) + Button( + onClick = { polarBleManager.triggerCooldown(sessionId) }, + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + ) { + 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, + ) + } + } + } + } + } + } + } + } +} + +@Composable +private fun MetricTile( + label: String, + value: String, + unit: String, + icon: ImageVector, + tint: Color, + subtext: String? = null, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + 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, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(8.dp), + color = Color(0xFF4CAF50).copy(alpha = 0.1f), + border = BorderStroke(1.dp, Color(0xFF4CAF50).copy(alpha = 0.3f)), + ) { + Column(modifier = Modifier.padding(8.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "-$drop bpm", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color(0xFF4CAF50), + ) + Text( + text = "($rating)", + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF4CAF50), + ) + } + } + } +} 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 index 97b33746e..58c7cd516 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt @@ -186,4 +186,46 @@ class PolarH10BleManagerTest { manager.processRrIntervals(PolarHrMeasurement(hr = 80, contactDetected = true, rrIntervalsMs = emptyList())) assertNull(manager.rrClockTimestampMs.value) } + + // ==================== 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() + } } 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 38f8a98a9..c42fe13a5 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 @@ -40,6 +40,7 @@ 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 @@ -304,10 +305,17 @@ fun ActiveWorkoutScreen( } when { workoutState is WorkoutState.Completed -> { - Logger.d { "ActiveWorkoutScreen: Workout completed, navigating back in 2s" } - delay(2000) - hasNavigatedAway = true - navController.navigateUp() + val hasPolarActivity = polarBleManager.isCooldownActive.value || + polarBleManager.connectionState.value is ConnectionState.Connected || + polarBleManager.latestSessionSummary.value != null + if (!hasPolarActivity) { + Logger.d { "ActiveWorkoutScreen: Workout completed (no Polar active), navigating back in 2s" } + delay(2000) + hasNavigatedAway = true + navController.navigateUp() + } else { + Logger.d { "ActiveWorkoutScreen: Workout completed with Polar telemetry/cooldown active; remaining on summary" } + } } workoutState is WorkoutState.Idle && workoutParameters.isJustLift -> { 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..f3337171e 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 @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController 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 @@ -252,6 +253,11 @@ fun RoutineCompleteScreen(navController: NavController, viewModel: MainViewModel } } } + + // Polar H10 Session Analytics & 5-minute recovery cooldown + PolarSessionAnalyticsCard( + modifier = Modifier.padding(top = Spacing.medium), + ) } } 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..808fc83b5 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,7 +44,10 @@ 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.presentation.components.PolarSessionAnalyticsCard import com.devil.phoenixproject.util.KmpUtils +import org.koin.compose.koinInject import kotlin.math.cos import kotlin.math.roundToInt import kotlin.math.sin @@ -108,9 +111,12 @@ 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 = koinInject(), ) { // State for RPE tracking var loggedRpe by remember { mutableStateOf(null) } + val isCooldownActive by polarBleManager.isCooldownActive.collectAsState() + 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 +129,7 @@ fun SetSummaryCard( autoplayEnabled = autoplayEnabled, summaryCountdownSeconds = summaryCountdownSeconds, isHistoryView = isHistoryView, - holdAutoContinue = holdAutoContinue, + holdAutoContinue = effectiveHoldAutoContinue, isTerminalSummary = isTerminalSummary, ) ) { @@ -141,12 +147,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 +433,13 @@ fun SetSummaryCard( } } + // Polar H10 Session Analytics Card + PolarSessionAnalyticsCard( + sessionId = summary.sessionId, + polarBleManager = polarBleManager, + 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 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..fa24d041a 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,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import com.devil.phoenixproject.presentation.components.PolarSessionAnalyticsCard import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -985,6 +986,9 @@ private fun CompletedCard( ) Spacer(modifier = Modifier.height(Spacing.small)) + // Polar H10 Session Analytics & Cooldown Protocol + PolarSessionAnalyticsCard(modifier = Modifier.padding(vertical = Spacing.small)) + // Check if this is a routine with more exercises val hasMoreExercises = loadedRoutine != null && currentExerciseIndex < (loadedRoutine.exercises.size - 1) From 5078b8af7a9498bef2328206e47d9ab9d64c4ce7 Mon Sep 17 00:00:00 2001 From: StuGotz Date: Tue, 20 Oct 2026 07:41:28 -0500 Subject: [PATCH 07/25] feat(polar): add 0-deg posture tare, HistoryTab integration, and telemetry canvas charts --- .../repository/SqlDelightWorkoutRepository.kt | 18 + .../data/repository/WorkoutRepository.kt | 12 + .../components/PolarH10WorkoutStrip.kt | 102 +- .../components/PolarSessionAnalyticsCard.kt | 1205 ++++++++++++++--- .../testutil/FakeWorkoutRepository.kt | 15 + .../presentation/screen/HistoryTab.kt | 13 + 6 files changed, 1162 insertions(+), 203 deletions(-) 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 8652324ec..31b911589 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 @@ -3,7 +3,10 @@ package com.devil.phoenixproject.data.repository import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToList import co.touchlab.kermit.Logger +import com.devil.phoenixproject.database.MetricSample import com.devil.phoenixproject.database.PhoenixDatabase +import com.devil.phoenixproject.database.PolarBeatSample +import com.devil.phoenixproject.database.PolarEcgSegment import com.devil.phoenixproject.database.Routine as RoutineRow import com.devil.phoenixproject.domain.model.EchoLevel import com.devil.phoenixproject.domain.model.Exercise @@ -1686,6 +1689,21 @@ class SqlDelightWorkoutRepository( withContext(Dispatchers.IO) { queries.selectExerciseIdsWithVelocityData(profileId).executeAsList() } + + override suspend fun getPolarBeatSamplesBySession(sessionId: String): List = + withContext(Dispatchers.IO) { + queries.selectPolarBeatSamplesBySession(sessionId).executeAsList() + } + + override suspend fun getPolarEcgSegmentsBySession(sessionId: String): List = + withContext(Dispatchers.IO) { + queries.selectPolarEcgSegmentsBySession(sessionId).executeAsList() + } + + override suspend fun getMetricSamplesBySession(sessionId: String): List = + withContext(Dispatchers.IO) { + queries.selectMetricsBySession(sessionId).executeAsList() + } } /** 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..6e4c500e0 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 @@ -1,5 +1,8 @@ package com.devil.phoenixproject.data.repository +import com.devil.phoenixproject.database.MetricSample +import com.devil.phoenixproject.database.PolarBeatSample +import com.devil.phoenixproject.database.PolarEcgSegment import com.devil.phoenixproject.domain.model.BiomechanicsRepResult import com.devil.phoenixproject.domain.model.CompletedSet import com.devil.phoenixproject.domain.model.RepMetricData @@ -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 raw metric 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/presentation/components/PolarH10WorkoutStrip.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt index dd24d1bca..7fbe632c6 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt @@ -5,6 +5,7 @@ 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 @@ -102,9 +103,12 @@ private fun PolarH10WorkoutStripContent( // Rolling ECG samples (µV) val ecgSamples = remember { mutableStateListOf() } - // Latest accelerometer sample from dedicated StateFlow + // 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() // 1. Auto-start ECG & ACC on enter, cleanly stop on dispose LaunchedEffect(Unit) { @@ -249,43 +253,77 @@ private fun PolarH10WorkoutStripContent( ) } - // Section 4: Live Chest Acceleration (mG) - Column( - modifier = Modifier.padding(start = 6.dp), - horizontalAlignment = Alignment.End, + // 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), ) { - val acc = latestAccSample - if (acc != null) { - val magnitudeG = sqrt( - (acc.xMg.toDouble() * acc.xMg + acc.yMg.toDouble() * acc.yMg + acc.zMg.toDouble() * acc.zMg), - ) / 1000.0 + 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 = "${(magnitudeG * 100).roundToInt() / 100.0} G", - style = MaterialTheme.typography.labelSmall, - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.primary, - ) - Text( - text = "ACC 50Hz", + 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, ) - } else { - Text( - text = "ACC", - style = MaterialTheme.typography.labelSmall, - fontSize = 9.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "—", - style = MaterialTheme.typography.labelSmall, - fontSize = 10.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + } + + Surface( + onClick = { polarBleManager.tareCurrentPosture() }, + shape = RoundedCornerShape(6.dp), + 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, + ) + } } } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt index 7a51bf0ff..2ceeea489 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt @@ -4,7 +4,11 @@ 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 @@ -15,30 +19,51 @@ 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.database.MetricSample +import com.devil.phoenixproject.database.PolarBeatSample +import com.devil.phoenixproject.database.PolarEcgSegment import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.ui.theme.Spacing import org.koin.compose.koinInject +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), and the 5-minute - * Heart Rate Recovery (HRR) cooldown protocol with real-time countdown, drop tracking, and control. + * 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, modifier: Modifier = Modifier, polarBleManager: PolarH10BleManager = koinInject(), + workoutRepository: WorkoutRepository = koinInject(), ) { val connectionState by polarBleManager.connectionState.collectAsState() val isCooldownActive by polarBleManager.isCooldownActive.collectAsState() @@ -49,13 +74,57 @@ fun PolarSessionAnalyticsCard( val latestSummary by polarBleManager.latestSessionSummary.collectAsState() val hrMeasurement by polarBleManager.hrMeasurement.collectAsState() - val summary = latestSummary ?: remember(sessionId) { - sessionId?.let { polarBleManager.getSessionSummary(it) } + val resolvedSessionId = sessionId ?: session?.id + val fallbackSummary = remember(session, resolvedSessionId) { + session?.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( + 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) } + } + + val summary = latestSummary ?: 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) { + if (resolvedSessionId != null) { + 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 = connectionState is ConnectionState.Connected - val hasCardiacData = summary?.avgHeartRateBpm != null || summary?.maxHeartRateBpm != null || isCooldownActive - val hasBiomechanicsData = summary?.maxTorsoPitchDeg != null || summary?.maxTorsoRollDeg != null + val hasCardiacData = summary?.avgHeartRateBpm != null || summary?.maxHeartRateBpm != null || isCooldownActive || beatSamples.isNotEmpty() || ecgSegments.isNotEmpty() + val hasBiomechanicsData = summary?.maxTorsoPitchDeg != null || summary?.maxTorsoRollDeg != null || metricSamples.any { it.torsoPitchDeg != null || it.torsoRollDeg != null } // If no Polar data and not connected or in cooldown, hide the card if (!hasCardiacData && !hasBiomechanicsData && !isConnected && !isCooldownActive) { @@ -252,209 +321,225 @@ fun PolarSessionAnalyticsCard( } // Section 4: 5-Minute Heart Rate Recovery (HRR) Protocol Card - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - 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), + val hasRecoveryData = summary?.hrr60Bpm != null || summary?.hrr300Bpm != null || isCooldownActive + if (hasRecoveryData || isConnected) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + 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), + ), ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row( + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.SpaceBetween, ) { - 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 - val formattedTime = "${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}" - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.primary, + 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 = formattedTime, - style = MaterialTheme.typography.labelMedium, + text = "5-MIN POST-WORKOUT COOLDOWN", + style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + color = MaterialTheme.colorScheme.primary, ) } + + if (isCooldownActive) { + val mins = cooldownSecondsRemaining / 60 + val secs = cooldownSecondsRemaining % 60 + Surface( + shape = RoundedCornerShape(6.dp), + 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) { - val progress = (300 - cooldownSecondsRemaining) / 300f - LinearProgressIndicator( - progress = { progress }, - modifier = Modifier - .fillMaxWidth() - .height(6.dp) - .clip(RoundedCornerShape(3.dp)), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surfaceVariant, - ) + if (isCooldownActive) { + // Active Cooldown Display + val currentHr = hrMeasurement?.hr + val dropSoFar = if (currentHr != null && cooldownStartHr != null) { + (cooldownStartHr!! - currentHr).coerceAtLeast(0) + } else null - val currentHr = hrMeasurement?.hr - val startHr = cooldownStartHr - if (currentHr != null && startHr != null) { - val drop = (startHr - currentHr).coerceAtLeast(0) - Text( - text = "Peak: $startHr bpm ➔ Current: $currentHr bpm (Drop: -$drop bpm)", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + 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, + ) + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // 1-min HRR status - if (cooldownHrr60 != null) { - Surface( - shape = RoundedCornerShape(8.dp), - color = Color(0xFF4CAF50).copy(alpha = 0.15f), - modifier = Modifier.weight(1f), - ) { - Row( - modifier = Modifier.padding(8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + dropSoFar?.let { drop -> + Surface( + shape = RoundedCornerShape(8.dp), + color = Color(0xFF4CAF50).copy(alpha = 0.15f), + border = BorderStroke(1.dp, Color(0xFF4CAF50).copy(alpha = 0.4f)), ) { - Icon( - Icons.Default.CheckCircle, - contentDescription = null, - tint = Color(0xFF4CAF50), - modifier = Modifier.size(16.dp), - ) Text( - text = "1m HRR: -${cooldownHrr60} bpm", - style = MaterialTheme.typography.labelSmall, + text = "-$drop bpm", + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = Color(0xFF4CAF50), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), ) } } - } else { - val secTo60 = (60 - (300 - cooldownSecondsRemaining)).coerceAtLeast(0) - Text( - text = "Measuring 1m HRR in ${secTo60}s...", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) } - OutlinedButton( - onClick = { polarBleManager.stopCooldown() }, - shape = RoundedCornerShape(8.dp), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), - ) { - Text( - "Finish Cooldown", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.SemiBold, - ) - } - } - } else { - // Cooldown is NOT active - val recordedHrr60 = cooldownHrr60 ?: summary?.hrr60Bpm - val recordedHrr300 = cooldownHrr300 ?: summary?.hrr300Bpm + // Active progress bar (300s total) + val progress = 1f - (cooldownSecondsRemaining / 300f).coerceIn(0f, 1f) + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(3.dp)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) - if (recordedHrr60 != null || recordedHrr300 != null) { + // Cancel Button Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.End, ) { - recordedHrr60?.let { hrr -> - val rating = if (hrr >= 25) "Excellent" else if (hrr >= 15) "Good" else "Moderate" - RecoveryChip( - label = "1-Min Recovery", - drop = hrr, - rating = rating, - modifier = Modifier.weight(1f), - ) - } - recordedHrr300?.let { hrr -> - val rating = if (hrr >= 45) "Excellent" else if (hrr >= 30) "Good" else "Moderate" - RecoveryChip( - label = "5-Min Recovery", - drop = hrr, - rating = rating, - modifier = Modifier.weight(1f), - ) + 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 if (isConnected) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "Track your autonomic cardiac recovery for 5 minutes.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f).padding(end = 8.dp), - ) - Button( - onClick = { polarBleManager.triggerCooldown(sessionId) }, - shape = RoundedCornerShape(8.dp), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + } else { + // Historical or Post-Cooldown State + val hrr60 = summary?.hrr60Bpm ?: cooldownHrr60 + val hrr300 = summary?.hrr300Bpm ?: cooldownHrr300 + + 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 = when { + drop60 >= 25 -> "Elite" + drop60 >= 18 -> "Good" + drop60 >= 12 -> "Fair" + else -> "Elevated Risk" + }, + modifier = Modifier.weight(1f), + ) + } + hrr300?.let { drop300 -> + RecoveryChip( + label = "5-Min Recovery (HRR-300)", + drop = drop300, + rating = when { + drop300 >= 50 -> "Excellent" + drop300 >= 35 -> "Optimal" + else -> "Slow" + }, + modifier = Modifier.weight(1f), + ) + } + } + } else if (isConnected) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - 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, + text = "Track vagal tone reactivation & recovery slope.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + FilledTonalButton( + onClick = { + resolvedSessionId?.let { polarBleManager.triggerCooldown(it) } + }, + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + ) { + 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, + ) + } } } } } } } + + // 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) + } } } } @@ -564,3 +649,781 @@ private fun RecoveryChip( } } } + +/** + * 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 != null && 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 = RoundedCornerShape(12.dp), + 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( + 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, + ) + } + + // 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 = RoundedCornerShape(6.dp), + 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 = "${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, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "HR: ${selectedSample.hrBpm} bpm", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color(0xFFFF5252), + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + ) + 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, + ) + } + } + } + } + } + + // Canvas Line Chart + Box( + modifier = Modifier + .fillMaxWidth() + .height(150.dp) + .clip(RoundedCornerShape(8.dp)) + .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) + } + ) + }, + ) { + 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 = Color.White.copy(alpha = 0.8f), + 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 = Color.White, 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 (`MetricSample`). + * 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] + } + } + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + 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( + 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, + ) + } + + // Legend Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.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) + } + + // Scrub Info Bar + if (selectedSample != null) { + Surface( + shape = RoundedCornerShape(6.dp), + 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 } ?: 0.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 } ?: 0.0)}°", + style = MaterialTheme.typography.labelSmall, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = Color(0xFF00BCD4), + ) + selectedSample.position?.let { pos -> + Text( + text = "Cable: ${(pos * 100).roundToInt()}%", + style = MaterialTheme.typography.labelSmall, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = Color(0xFFAB47BC), + ) + } + } + } + } + + // Canvas Plot + Box( + modifier = Modifier + .fillMaxWidth() + .height(150.dp) + .clip(RoundedCornerShape(8.dp)) + .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) + } + ) + }, + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val w = size.width + val h = size.height + + // Center 0° baseline for roll/pitch reference + val midY = h * 0.65f + drawLine( + color = Color(0xFF1E3A5F), + start = Offset(0f, midY), + end = Offset(w, midY), + strokeWidth = 1f, + ) + + val maxPitch = 90f // 0° to 90° forward bend + val maxRoll = 30f // ±30° lateral tilt + + val stepX = w / (sampleCount - 1).coerceAtLeast(1).toFloat() + + // 1. Cable Position (Dashed / fine purple line at top half) + val cablePath = Path() + var cableStarted = false + biomechSamples.forEachIndexed { i, s -> + s.position?.let { pos -> + val x = i * stepX + val y = (h * 0.45f) - (pos.toFloat().coerceIn(0f, 1f) * (h * 0.4f)) + if (!cableStarted) { + cablePath.moveTo(x, y) + cableStarted = true + } else { + cablePath.lineTo(x, y) + } + } + } + if (cableStarted) { + drawPath( + path = cablePath, + color = Color(0xFFAB47BC).copy(alpha = 0.6f), + style = Stroke(width = 1.5.dp.toPx()), + ) + } + + // 2. Torso Pitch (Hip Hinge, orange line) + val pitchPath = Path() + var pitchStarted = false + biomechSamples.forEachIndexed { i, s -> + s.torsoPitchDeg?.let { pitch -> + val x = i * stepX + val y = midY - (pitch.toFloat().coerceIn(0f, maxPitch) / maxPitch) * (midY - 10f) + 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) + val rollPath = Path() + var rollStarted = false + biomechSamples.forEachIndexed { i, s -> + s.torsoRollDeg?.let { roll -> + val x = i * stepX + val y = midY - (roll.toFloat().coerceIn(-maxRoll, maxRoll) / maxRoll) * (h * 0.25f) + 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 + scrubFraction?.let { fraction -> + val scrubX = fraction * w + drawLine( + color = Color.White.copy(alpha = 0.8f), + start = Offset(scrubX, 0f), + end = Offset(scrubX, h), + strokeWidth = 1.5.dp.toPx(), + ) + } + } + } + } + } +} + +/** + * 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 allSamples = remember(segments) { + val list = mutableListOf() + for (seg in segments) { + val parts = seg.samplesUvCsv.split(',') + for (p in parts) { + p.trim().toIntOrNull()?.let { list.add(it) } + } + } + list + } + if (allSamples.size < 10) return + + val windowSize = 260 // 2.0 seconds at 130 Hz + var windowStart by remember { mutableStateOf(0) } + val maxStart = (allSamples.size - windowSize).coerceAtLeast(0) + + val currentPhase = remember(windowStart, segments) { + var accum = 0 + var phase = segments.first().phase + for (seg in segments) { + val count = seg.samplesUvCsv.count { it == ',' } + 1 + if (windowStart in accum until (accum + count)) { + phase = seg.phase + break + } + accum += count + } + phase + } + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + 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( + 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 = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f), + ) { + Text( + text = currentPhase, + style = MaterialTheme.typography.labelSmall, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + 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(allSamples.size)) / 130.0 * 10).roundToInt() / 10.0 + val totalSec = (allSamples.size / 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, + ) + Text( + text = "${allSamples.size} samples @ 130 Hz", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + ) + } + + 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(RoundedCornerShape(8.dp)) + .background(Color(0xFF081422)) + .border(0.5.dp, Color(0xFF1B3A5A), RoundedCornerShape(8.dp)), + ) { + val windowSlice = remember(windowStart, allSamples) { + allSamples.subList(windowStart, (windowStart + windowSize).coerceAtMost(allSamples.size)) + } + + 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/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt index 99a416c0d..5e44ce9c1 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt @@ -1,6 +1,9 @@ package com.devil.phoenixproject.testutil import com.devil.phoenixproject.data.repository.CompletedSetRepository +import com.devil.phoenixproject.database.MetricSample +import com.devil.phoenixproject.database.PolarBeatSample +import com.devil.phoenixproject.database.PolarEcgSegment import com.devil.phoenixproject.data.repository.MAX_RECENT_EXERCISE_SESSIONS import com.devil.phoenixproject.data.repository.RepMetricRepository import com.devil.phoenixproject.data.repository.RoutineCsvImportConflictException @@ -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/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/HistoryTab.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/HistoryTab.kt index 70c0e1ee1..04bd2e45a 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 @@ -75,6 +75,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 @@ -561,6 +562,12 @@ 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, + ) } } @@ -1172,6 +1179,12 @@ private fun GroupedRoutineCard( // Biomechanics Section per session (v0.5.0+, tier-gated) BiomechanicsSection(session = session) + // Polar H10 Cardiac & Biomechanics Telemetry Section + PolarSessionAnalyticsCard( + sessionId = session.id, + session = session, + ) + if (index < groupedItem.sessions.size - 1) { Spacer(modifier = Modifier.height(Spacing.medium)) } From 3d4105a8fd16e516b9ba145a7bfecdf42ce18137 Mon Sep 17 00:00:00 2001 From: StuGotz Date: Tue, 20 Oct 2026 10:37:11 -0500 Subject: [PATCH 08/25] fix(polar): resolve ConcurrentModificationException, pitch sign, cooldown persistence, and chart scaling --- .../data/ble/PolarH10BleManager.kt | 89 +++++++--- .../components/PolarSessionAnalyticsCard.kt | 168 ++++++++++++++---- .../manager/DefaultWorkoutSessionManager.kt | 18 +- .../util/PolarTelemetryUtils.kt | 2 +- .../data/ble/PolarH10BleManagerTest.kt | 44 +++++ .../util/PolarTelemetryUtilsTest.kt | 22 ++- 6 files changed, 282 insertions(+), 61 deletions(-) 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 index a55953f51..020fefcb5 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -37,6 +37,8 @@ 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 @@ -130,6 +132,7 @@ class PolarH10BleManager( } // ===== Session Recording & Recovery State ===== + private val sessionLock = reentrantLock() private var activeSessionId: String? = null private var currentPhase: String = "WARMUP" private var cooldownJob: Job? = null @@ -176,11 +179,13 @@ class PolarH10BleManager( _activeCooldownSessionId.value = null activeSessionId = sessionId currentPhase = phase - sessionHrs.clear() - sessionRrs.clear() - recentRrs.clear() - maxTorsoPitch = null - maxTorsoRoll = null + sessionLock.withLock { + sessionHrs.clear() + sessionRrs.clear() + recentRrs.clear() + maxTorsoPitch = null + maxTorsoRoll = null + } endHeartRate = null hrr60 = null hrr300 = null @@ -198,6 +203,10 @@ class PolarH10BleManager( fun triggerCooldown(sessionId: String? = null) { endSessionRecording(sessionId) + if (_connectionState.value is ConnectionState.Connected) { + if (!isAccStreaming) scope.launch { startAccStream() } + if (!isEcgStreaming) scope.launch { startEcgStream() } + } } fun stopCooldown() { @@ -226,12 +235,13 @@ class PolarH10BleManager( } fun endSessionRecording(sessionId: String? = null) { - val targetSessionId = sessionId ?: activeSessionId ?: return + val targetSessionId = sessionId ?: activeSessionId ?: _activeCooldownSessionId.value ?: return if (activeSessionId == null) { activeSessionId = targetSessionId } _activeCooldownSessionId.value = targetSessionId - endHeartRate = _hrMeasurement.value?.hr ?: sessionHrs.lastOrNull() + val lastHr = sessionLock.withLock { sessionHrs.lastOrNull() } + endHeartRate = _hrMeasurement.value?.hr ?: lastHr _cooldownStartHeartRate.value = endHeartRate currentPhase = "POST_WORKOUT_COOLDOWN" _isCooldownActive.value = true @@ -283,13 +293,28 @@ class PolarH10BleManager( } } + private data class SessionSampleSnapshot( + val hrs: List, + val rrs: List, + val maxPitch: Double?, + val rollMax: Double?, + ) + fun getSessionSummary(sessionId: String): PolarSessionSummary? { if (activeSessionId != null && activeSessionId != sessionId) return null - val avgHr = if (sessionHrs.isNotEmpty()) sessionHrs.average().roundToInt() else null - val maxHr = sessionHrs.maxOrNull() - val minHr = sessionHrs.minOrNull() - val avgRr = if (sessionRrs.isNotEmpty()) sessionRrs.average().toFloat() else null - val rmssd = PolarTelemetryUtils.calculateRmssdMs(sessionRrs)?.toFloat() + 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 avgRr = if (snapshot.rrs.isNotEmpty()) snapshot.rrs.average().toFloat() else null + val rmssd = PolarTelemetryUtils.calculateRmssdMs(snapshot.rrs)?.toFloat() return PolarSessionSummary( avgHeartRateBpm = avgHr, maxHeartRateBpm = maxHr, @@ -298,8 +323,8 @@ class PolarH10BleManager( rmssdMs = rmssd, hrr60Bpm = hrr60, hrr300Bpm = hrr300, - maxTorsoPitchDeg = maxTorsoPitch?.toFloat(), - maxTorsoRollDeg = maxTorsoRoll?.toFloat(), + maxTorsoPitchDeg = snapshot.maxPitch?.toFloat(), + maxTorsoRollDeg = snapshot.rollMax?.toFloat(), ) } @@ -649,6 +674,11 @@ class PolarH10BleManager( e.rethrowIfCancellation() log.w { "Pre-subscribing PMD non-fatal (will retry on stream start): ${e.message}" } } + + if (activeSessionId != null) { + if (!isAccStreaming) scope.launch { startAccStream() } + if (!isEcgStreaming) scope.launch { startEcgStream() } + } } private fun startHrObservation(p: Peripheral) { @@ -708,11 +738,13 @@ class PolarH10BleManager( val currentSid = activeSessionId if (currentSid != null) { - sessionHrs.add(beat.hr) - sessionRrs.add(beat.rrIntervalMs) - recentRrs.addLast(beat.rrIntervalMs) - if (recentRrs.size > 30) recentRrs.removeFirst() - val currentRmssd = PolarTelemetryUtils.calculateRmssdMs(recentRrs.toList()) + val currentRmssd = sessionLock.withLock { + sessionHrs.add(beat.hr) + sessionRrs.add(beat.rrIntervalMs) + recentRrs.addLast(beat.rrIntervalMs) + if (recentRrs.size > 30) recentRrs.removeFirst() + PolarTelemetryUtils.calculateRmssdMs(recentRrs.toList()) + } try { database?.phoenixDatabaseQueries?.insertPolarBeatSample( sessionId = currentSid, @@ -814,11 +846,20 @@ class PolarH10BleManager( _torsoRollDeg.value = bio.rollDeg _dynamicAccMg.value = bio.dynamicAccMg if (activeSessionId != null) { - if (maxTorsoPitch == null || bio.pitchDeg > maxTorsoPitch!!) { - maxTorsoPitch = bio.pitchDeg - } - if (maxTorsoRoll == null || abs(bio.rollDeg) > abs(maxTorsoRoll!!)) { - maxTorsoRoll = bio.rollDeg + 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 + } } } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt index 2ceeea489..380169238 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt @@ -73,8 +73,9 @@ fun PolarSessionAnalyticsCard( val cooldownStartHr by polarBleManager.cooldownStartHeartRate.collectAsState() val latestSummary by polarBleManager.latestSessionSummary.collectAsState() val hrMeasurement by polarBleManager.hrMeasurement.collectAsState() + val activeCooldownSessionId by polarBleManager.activeCooldownSessionId.collectAsState() - val resolvedSessionId = sessionId ?: session?.id + val resolvedSessionId = sessionId ?: session?.id ?: activeCooldownSessionId ?: polarBleManager.currentRecordingSessionId val fallbackSummary = remember(session, resolvedSessionId) { session?.let { s -> if (s.avgHeartRateBpm != null || s.maxHeartRateBpm != null || s.minHeartRateBpm != null || @@ -160,6 +161,7 @@ fun PolarSessionAnalyticsCard( horizontalArrangement = Arrangement.SpaceBetween, ) { Row( + modifier = Modifier.weight(1f).padding(end = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -188,12 +190,15 @@ fun PolarSessionAnalyticsCard( 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, ) } } @@ -215,6 +220,8 @@ fun PolarSessionAnalyticsCard( } else { MaterialTheme.colorScheme.onSurfaceVariant }, + softWrap = false, + maxLines = 1, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), ) } @@ -413,16 +420,18 @@ fun PolarSessionAnalyticsCard( } dropSoFar?.let { drop -> + val isWarning = drop < 12 && cooldownSecondsRemaining <= 240 + val tint = if (isWarning) Color(0xFFFF9800) else Color(0xFF4CAF50) Surface( shape = RoundedCornerShape(8.dp), - color = Color(0xFF4CAF50).copy(alpha = 0.15f), - border = BorderStroke(1.dp, Color(0xFF4CAF50).copy(alpha = 0.4f)), + 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 = Color(0xFF4CAF50), + color = tint, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), ) } @@ -501,13 +510,15 @@ fun PolarSessionAnalyticsCard( text = "Track vagal tone reactivation & recovery slope.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f).padding(end = 8.dp), ) FilledTonalButton( onClick = { resolvedSessionId?.let { polarBleManager.triggerCooldown(it) } }, shape = RoundedCornerShape(8.dp), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp), + modifier = Modifier.wrapContentWidth(), ) { Icon( Icons.Default.PlayArrow, @@ -519,6 +530,8 @@ fun PolarSessionAnalyticsCard( "Start Cooldown", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, + softWrap = false, + maxLines = 1, ) } } @@ -618,17 +631,21 @@ private fun RecoveryChip( rating: String, modifier: Modifier = Modifier, ) { + val isWarning = drop < 12 + val tint = if (isWarning) Color(0xFFFF9800) else Color(0xFF4CAF50) Surface( modifier = modifier, shape = RoundedCornerShape(8.dp), - color = Color(0xFF4CAF50).copy(alpha = 0.1f), - border = BorderStroke(1.dp, Color(0xFF4CAF50).copy(alpha = 0.3f)), + 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, @@ -638,12 +655,14 @@ private fun RecoveryChip( text = "-$drop bpm", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = Color(0xFF4CAF50), + color = tint, ) Text( text = "($rating)", style = MaterialTheme.typography.labelSmall, - color = Color(0xFF4CAF50), + color = tint, + softWrap = false, + maxLines = 1, ) } } @@ -697,6 +716,7 @@ private fun ContinuousHeartRateTimelineChart( horizontalArrangement = Arrangement.SpaceBetween, ) { Row( + modifier = Modifier.weight(1f).padding(end = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { @@ -720,6 +740,8 @@ private fun ContinuousHeartRateTimelineChart( style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + softWrap = false, + maxLines = 1, ) } @@ -962,6 +984,35 @@ private fun TorsoBiomechanicsVsCableChart( } } + // 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 = RoundedCornerShape(12.dp), @@ -978,6 +1029,7 @@ private fun TorsoBiomechanicsVsCableChart( horizontalArrangement = Arrangement.SpaceBetween, ) { Row( + modifier = Modifier.weight(1f).padding(end = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { @@ -1001,22 +1053,39 @@ private fun TorsoBiomechanicsVsCableChart( style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + softWrap = false, + maxLines = 1, ) } - // Legend Row + // Legend & Dynamic Axis Info Row Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), + 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 = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.surfaceContainerHighest, @@ -1041,7 +1110,7 @@ private fun TorsoBiomechanicsVsCableChart( fontWeight = FontWeight.Bold, color = Color(0xFF00BCD4), ) - selectedSample.position?.let { pos -> + cablePos?.let { pos -> Text( text = "Cable: ${(pos * 100).roundToInt()}%", style = MaterialTheme.typography.labelSmall, @@ -1087,27 +1156,41 @@ private fun TorsoBiomechanicsVsCableChart( val w = size.width val h = size.height - // Center 0° baseline for roll/pitch reference - val midY = h * 0.65f + 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, midY), - end = Offset(w, midY), - strokeWidth = 1f, + start = Offset(0f, zeroY), + end = Offset(w, zeroY), + strokeWidth = 1.2f, ) - val maxPitch = 90f // 0° to 90° forward bend - val maxRoll = 30f // ±30° lateral tilt + // 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 (Dashed / fine purple line at top half) + // 1. Cable Position (Purple line scaled independently across full height) val cablePath = Path() var cableStarted = false biomechSamples.forEachIndexed { i, s -> - s.position?.let { pos -> + 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 = (h * 0.45f) - (pos.toFloat().coerceIn(0f, 1f) * (h * 0.4f)) + val y = padTop + (1f - (pos - minPos) / posRange) * plotH if (!cableStarted) { cablePath.moveTo(x, y) cableStarted = true @@ -1119,18 +1202,22 @@ private fun TorsoBiomechanicsVsCableChart( if (cableStarted) { drawPath( path = cablePath, - color = Color(0xFFAB47BC).copy(alpha = 0.6f), - style = Stroke(width = 1.5.dp.toPx()), + 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) + // 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 = midY - (pitch.toFloat().coerceIn(0f, maxPitch) / maxPitch) * (midY - 10f) + val y = padTop + (1f - (pitch.toFloat() - minAngle) / angleRange) * plotH if (!pitchStarted) { pitchPath.moveTo(x, y) pitchStarted = true @@ -1151,13 +1238,13 @@ private fun TorsoBiomechanicsVsCableChart( ) } - // 3. Torso Roll (Lateral Tilt, cyan line) + // 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 = midY - (roll.toFloat().coerceIn(-maxRoll, maxRoll) / maxRoll) * (h * 0.25f) + val y = padTop + (1f - (roll.toFloat() - minAngle) / angleRange) * plotH if (!rollStarted) { rollPath.moveTo(x, y) rollStarted = true @@ -1178,7 +1265,7 @@ private fun TorsoBiomechanicsVsCableChart( ) } - // 4. Scrubber Line + // 4. Scrubber Line & Indicators scrubFraction?.let { fraction -> val scrubX = fraction * w drawLine( @@ -1187,6 +1274,21 @@ private fun TorsoBiomechanicsVsCableChart( end = Offset(scrubX, h), strokeWidth = 1.5.dp.toPx(), ) + selectedSample?.torsoPitchDeg?.let { pitch -> + val pitchY = padTop + (1f - (pitch.toFloat() - minAngle) / angleRange) * plotH + drawCircle(color = Color.White, 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 = Color.White, radius = 4.dp.toPx(), center = Offset(scrubX, cableY)) + drawCircle(color = Color(0xFFAB47BC), radius = 2.5.dp.toPx(), center = Offset(scrubX, cableY)) + } } } } @@ -1251,6 +1353,7 @@ private fun SavedEcgWaveformScrubber( horizontalArrangement = Arrangement.SpaceBetween, ) { Row( + modifier = Modifier.weight(1f).padding(end = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { @@ -1274,11 +1377,13 @@ private fun SavedEcgWaveformScrubber( color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f), ) { Text( - text = currentPhase, + 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), ) } @@ -1299,12 +1404,15 @@ private fun SavedEcgWaveformScrubber( 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 = "${allSamples.size} samples @ 130 Hz", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + softWrap = false, + maxLines = 1, ) } 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 d498e191a..de1ab6c8c 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 @@ -532,7 +532,10 @@ class DefaultWorkoutSessionManager( polarBleManager.updateSessionPhase(phase) } is WorkoutState.Completed, WorkoutState.RoutineComplete -> { - polarBleManager.endSessionRecording() + polarBleManager.endSessionRecording(coordinator.currentSessionId) + } + is WorkoutState.SetSummary -> { + polarBleManager.endSessionRecording(state.sessionId ?: coordinator.currentSessionId) } else -> {} } @@ -542,6 +545,19 @@ class DefaultWorkoutSessionManager( Logger.e(e) { "Error in Polar session phase collector" } } } + + scope.launch { + try { + coordinator.routineFlowState.collect { flowState -> + if (flowState is RoutineFlowState.Complete) { + polarBleManager.endSessionRecording(coordinator.currentSessionId) + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Logger.e(e) { "Error in Polar routine flow collector" } + } + } } scope.launch { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt index cb42bb13e..988f51872 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt @@ -40,7 +40,7 @@ object PolarTelemetryUtils { tarePitchDeg: Double = 0.0, ): Double { val planarNorm = sqrt(xMg * xMg + yMg * yMg) - val rawAngleRad = atan2(zMg, planarNorm) + val rawAngleRad = atan2(-zMg, planarNorm) val rawAngleDeg = rawAngleRad * (180.0 / PI) return rawAngleDeg - tarePitchDeg } 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 index 58c7cd516..db565ce7b 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt @@ -228,4 +228,48 @@ class PolarH10BleManagerTest { 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() + } } + diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtilsTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtilsTest.kt index 9d352f86f..96b3a7f84 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtilsTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtilsTest.kt @@ -25,10 +25,10 @@ class PolarTelemetryUtilsTest { @Test fun fortyFiveDegreeForwardHingeProducesFortyFiveDegreesPitch() { - // 45° forward hinge: gravity splits between -X (707 mG) and +Z (707 mG) + // 45° forward hinge: gravity pulls along -X (707 mG) and -Z (707 mG) val x = -707.106 val y = 0.0 - val z = 707.106 + val z = -707.106 val metrics = PolarTelemetryUtils.computeTorsoBiomechanics(x, y, z) @@ -37,12 +37,24 @@ class PolarTelemetryUtilsTest { assertEquals(0, metrics.dynamicAccMg, "Static hinge dynamic acceleration should be ~0 mG") } + @Test + fun backwardLeanProducesNegativePitch() { + // Leaning backward: gravity pulls along +Z + val x = -707.106 + val y = 0.0 + val z = 707.106 + + val metrics = PolarTelemetryUtils.computeTorsoBiomechanics(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 + // 90° horizontal bend (torso parallel to ground): gravity entirely along -Z val x = 0.0 val y = 0.0 - val z = 1000.0 + val z = -1000.0 val metrics = PolarTelemetryUtils.computeTorsoBiomechanics(x, y, z) @@ -80,7 +92,7 @@ class PolarTelemetryUtilsTest { // 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 + 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) From 1739093f2132b58db7785318ddd41267df63acb8 Mon Sep 17 00:00:00 2001 From: StuGotz Date: Tue, 20 Oct 2026 13:29:43 -0500 Subject: [PATCH 09/25] fix(polar): run cooldown only on final completion, move Polar cards below rep cards, and fix scrubber labels --- .../data/ble/PolarH10BleManager.kt | 71 +++++++++++++- .../components/PolarSessionAnalyticsCard.kt | 46 ++++++++- .../manager/DefaultWorkoutSessionManager.kt | 2 +- .../data/ble/PolarH10BleManagerTest.kt | 97 +++++++++++++++++++ .../presentation/screen/HistoryTab.kt | 19 ++-- .../presentation/screen/SetSummaryCard.kt | 15 +-- .../presentation/screen/WorkoutTab.kt | 54 +++++------ 7 files changed, 255 insertions(+), 49 deletions(-) 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 index 020fefcb5..23971aa45 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -144,6 +144,7 @@ class PolarH10BleManager( private var endHeartRate: Int? = null private var hrr60: Int? = null private var hrr300: Int? = null + private var cooldownStartTimestampMs: Long? = null private val _isCooldownActive = MutableStateFlow(false) val isCooldownActive: StateFlow = _isCooldownActive.asStateFlow() @@ -166,17 +167,21 @@ class PolarH10BleManager( private val _latestSessionSummary = MutableStateFlow(null) val latestSessionSummary: StateFlow = _latestSessionSummary.asStateFlow() + private val completedCooldownSessionIds = mutableSetOf() + val currentRecordingSessionId: String? get() = activeSessionId fun startSessionRecording(sessionId: String, phase: String = "WARMUP") { 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) activeSessionId = sessionId currentPhase = phase sessionLock.withLock { @@ -198,11 +203,28 @@ class PolarH10BleManager( } fun updateSessionPhase(phase: String) { + if (phase == "WORKING_SET" || phase == "WARMUP") { + if (_isCooldownActive.value || currentPhase == "POST_WORKOUT_COOLDOWN") { + cancelCooldown() + } + } currentPhase = phase } - fun triggerCooldown(sessionId: String? = null) { - endSessionRecording(sessionId) + fun cancelCooldown() { + cooldownJob?.cancel() + cooldownJob = null + cooldownStartTimestampMs = null + _isCooldownActive.value = false + _cooldownSecondsRemaining.value = 0 + val targetSessionId = activeSessionId ?: _activeCooldownSessionId.value + if (targetSessionId != null) { + completedCooldownSessionIds.remove(targetSessionId) + } + } + + fun triggerCooldown(sessionId: String? = null, forceRestart: Boolean = false) { + endSessionRecording(sessionId, forceRestart = forceRestart) if (_connectionState.value is ConnectionState.Connected) { if (!isAccStreaming) scope.launch { startAccStream() } if (!isEcgStreaming) scope.launch { startEcgStream() } @@ -213,6 +235,7 @@ class PolarH10BleManager( val targetSessionId = activeSessionId ?: _activeCooldownSessionId.value cooldownJob?.cancel() cooldownJob = null + cooldownStartTimestampMs = null val finalHr = _hrMeasurement.value?.hr val endHr = endHeartRate ?: _cooldownStartHeartRate.value if (finalHr != null && endHr != null) { @@ -231,11 +254,23 @@ class PolarH10BleManager( if (targetSessionId != null) { persistPolarSummary(targetSessionId) _latestSessionSummary.value = getSessionSummary(targetSessionId) + completedCooldownSessionIds.add(targetSessionId) } } - fun endSessionRecording(sessionId: String? = null) { + fun endSessionRecording(sessionId: String? = null, forceRestart: Boolean = false) { val targetSessionId = sessionId ?: activeSessionId ?: _activeCooldownSessionId.value ?: return + if (!forceRestart && completedCooldownSessionIds.contains(targetSessionId)) { + Logger.d { "endSessionRecording: Cooldown already completed for session $targetSessionId; skipping" } + return + } + if (!forceRestart && _isCooldownActive.value && _activeCooldownSessionId.value == targetSessionId) { + Logger.d { "endSessionRecording: Cooldown already active for session $targetSessionId; skipping restart" } + return + } + if (forceRestart) { + completedCooldownSessionIds.remove(targetSessionId) + } if (activeSessionId == null) { activeSessionId = targetSessionId } @@ -246,11 +281,16 @@ class PolarH10BleManager( 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 = timeSource() + cooldownStartTimestampMs = cooldownStartMs cooldownJob = scope.launch { - val cooldownStartMs = timeSource() var hrr60Persisted = false var hrr300Persisted = false while (isActive && (timeSource() - cooldownStartMs) < 300_000L) { @@ -260,6 +300,14 @@ class PolarH10BleManager( _cooldownSecondsRemaining.value = remainingSec val curHr = _hrMeasurement.value?.hr + // Bug C: Anchor peak HR during the first 30 seconds of cooldown + if (elapsedMs <= 30_000L && curHr != null) { + val peak = maxOf(endHeartRate ?: curHr, curHr) + if (endHeartRate != peak) { + endHeartRate = peak + _cooldownStartHeartRate.value = peak + } + } val endHr = endHeartRate if (elapsedMs >= 60_000L && !hrr60Persisted && curHr != null && endHr != null) { val drop = (endHr - curHr).coerceAtLeast(0) @@ -284,6 +332,7 @@ class PolarH10BleManager( _cooldownHrr300.value = drop persistPolarSummary(targetSessionId) } + completedCooldownSessionIds.add(targetSessionId) _isCooldownActive.value = false _cooldownSecondsRemaining.value = 0 _latestSessionSummary.value = getSessionSummary(targetSessionId) @@ -713,10 +762,22 @@ class PolarH10BleManager( * if gaps or drift exceed 5 seconds. */ internal fun processRrIntervals(measurement: PolarHrMeasurement) { + _hrMeasurement.value = measurement val rrs = measurement.rrIntervalsMs - if (rrs.isEmpty()) return val nowMs = timeSource() + if (_isCooldownActive.value) { + val startTs = cooldownStartTimestampMs + if (startTs != null && (nowMs - startTs) <= 30_000L) { + val peak = maxOf(endHeartRate ?: measurement.hr, measurement.hr) + if (endHeartRate != peak) { + endHeartRate = peak + _cooldownStartHeartRate.value = peak + } + } + } + + if (rrs.isEmpty()) return val previousBeat = lastBeatTimestampMs val startingTs: Long = if (previousBeat == null || (nowMs - previousBeat) > 5000L || previousBeat > nowMs + 5000L) { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt index 380169238..1acc53741 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt @@ -61,6 +61,7 @@ import kotlin.math.roundToInt fun PolarSessionAnalyticsCard( sessionId: String? = null, session: WorkoutSession? = null, + showCooldown: Boolean = true, modifier: Modifier = Modifier, polarBleManager: PolarH10BleManager = koinInject(), workoutRepository: WorkoutRepository = koinInject(), @@ -329,7 +330,7 @@ fun PolarSessionAnalyticsCard( // Section 4: 5-Minute Heart Rate Recovery (HRR) Protocol Card val hasRecoveryData = summary?.hrr60Bpm != null || summary?.hrr300Bpm != null || isCooldownActive - if (hasRecoveryData || isConnected) { + if (showCooldown && (hasRecoveryData || isConnected)) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), @@ -500,6 +501,30 @@ fun PolarSessionAnalyticsCard( ) } } + if (isConnected) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + onClick = { + resolvedSessionId?.let { polarBleManager.triggerCooldown(it, 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(), @@ -768,7 +793,7 @@ private fun ContinuousHeartRateTimelineChart( modifier = Modifier.fillMaxWidth(), ) { Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -778,14 +803,22 @@ private fun ContinuousHeartRateTimelineChart( 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)) { + 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( @@ -794,6 +827,8 @@ private fun ContinuousHeartRateTimelineChart( fontWeight = FontWeight.Bold, color = Color(0xFF00E676), fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + softWrap = false, + maxLines = 1, ) } } @@ -1111,12 +1146,15 @@ private fun TorsoBiomechanicsVsCableChart( color = Color(0xFF00BCD4), ) cablePos?.let { pos -> + val romPercent = ((pos - minPos) / posRange * 100f).roundToInt().coerceIn(0, 100) Text( - text = "Cable: ${(pos * 100).roundToInt()}%", + 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, ) } } 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 de1ab6c8c..6604ba116 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 @@ -535,7 +535,7 @@ class DefaultWorkoutSessionManager( polarBleManager.endSessionRecording(coordinator.currentSessionId) } is WorkoutState.SetSummary -> { - polarBleManager.endSessionRecording(state.sessionId ?: coordinator.currentSessionId) + polarBleManager.updateSessionPhase("REST") } else -> {} } 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 index db565ce7b..564aba0a7 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt @@ -20,6 +20,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest @OptIn(ExperimentalUuidApi::class) class PolarH10BleManagerTest { @@ -271,5 +273,100 @@ class PolarH10BleManagerTest { 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. Routine completes again -> cooldown starts again + manager.endSessionRecording("routine-session-1") + 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() + } } + 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 04bd2e45a..4ae468d02 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 @@ -1179,16 +1179,23 @@ private fun GroupedRoutineCard( // Biomechanics Section per session (v0.5.0+, tier-gated) BiomechanicsSection(session = session) - // Polar H10 Cardiac & Biomechanics Telemetry Section - PolarSessionAnalyticsCard( - sessionId = session.id, - session = session, - ) - if (index < groupedItem.sessions.size - 1) { Spacer(modifier = Modifier.height(Spacing.medium)) } } + + // Polar H10 Cardiac & Biomechanics Telemetry Section (rendered below all exercises & rep details) + val polarSession = groupedItem.sessions.find { + it.avgHeartRateBpm != null || it.maxHeartRateBpm != null || it.hrr60Bpm != null + } ?: groupedItem.sessions.lastOrNull() ?: groupedItem.sessions.firstOrNull() + + if (polarSession != null) { + Spacer(modifier = Modifier.height(Spacing.medium)) + PolarSessionAnalyticsCard( + sessionId = polarSession.id, + session = polarSession, + ) + } } } 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 808fc83b5..ab7417b89 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 @@ -433,12 +433,15 @@ fun SetSummaryCard( } } - // Polar H10 Session Analytics Card - PolarSessionAnalyticsCard( - sessionId = summary.sessionId, - polarBleManager = polarBleManager, - modifier = Modifier.padding(bottom = 12.dp), - ) + // Polar H10 Session Analytics Card (live view only; history renders at bottom below rep details) + if (!isHistoryView) { + PolarSessionAnalyticsCard( + sessionId = summary.sessionId, + polarBleManager = polarBleManager, + showCooldown = false, + modifier = Modifier.padding(bottom = 12.dp), + ) + } // Done/Continue button - only show in live view if (!isHistoryView) { 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 fa24d041a..02f13d10a 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 @@ -986,9 +986,6 @@ private fun CompletedCard( ) Spacer(modifier = Modifier.height(Spacing.small)) - // Polar H10 Session Analytics & Cooldown Protocol - PolarSessionAnalyticsCard(modifier = Modifier.padding(vertical = Spacing.small)) - // Check if this is a routine with more exercises val hasMoreExercises = loadedRoutine != null && currentExerciseIndex < (loadedRoutine.exercises.size - 1) @@ -1030,32 +1027,35 @@ 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)) - 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" From a768a61b6f91aa0cf0a87c2779a470ab99247499 Mon Sep 17 00:00:00 2001 From: StuGotz Date: Tue, 20 Oct 2026 16:09:19 -0500 Subject: [PATCH 10/25] fix(polar): exclude raw HR telemetry when backup toggle is off, reorder RoutineComplete card, and filter RMSSD artifacts --- .../util/DataBackupCompletenessTest.kt | 76 ++++++++++++---- .../data/ble/PolarH10BleManager.kt | 20 ++++- .../repository/SqlDelightWorkoutRepository.kt | 25 +++++- .../components/PolarSessionAnalyticsCard.kt | 25 +++++- .../presentation/util/PolarTelemetryUtils.kt | 14 ++- .../presentation/viewmodel/MainViewModel.kt | 6 ++ .../devil/phoenixproject/util/BackupModels.kt | 2 +- .../phoenixproject/util/DataBackupManager.kt | 89 +++++++++++-------- .../util/PolarTelemetryUtils.kt | 13 ++- .../util/PolarTelemetryUtilsTest.kt | 21 +++++ .../util/PolarTelemetryUtilsTest.kt | 20 +++++ .../screen/RoutineCompleteScreen.kt | 10 +-- 12 files changed, 248 insertions(+), 73 deletions(-) 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 c3496c8ce..f3e82f234 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupCompletenessTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupCompletenessTest.kt @@ -320,19 +320,17 @@ class DataBackupCompletenessTest { val exportedJson = source.manager.exportToJson() val backupData = testJson.decodeFromString(exportedJson) - assertEquals(1, backupData.data.polarBeatSamples.size) - assertEquals(1, backupData.data.polarEcgSegments.size) - // Metric samples: normal omitted, polar included - assertEquals(1, backupData.data.metricSamples.size) - assertEquals(145, backupData.data.metricSamples[0].heartRateBpm) + 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(1, result.polarBeatSamplesImported) - assertEquals(1, result.polarEcgSegmentsImported) + assertEquals(0, result.polarBeatSamplesImported) + assertEquals(0, result.polarEcgSegmentsImported) - // Verify target database + // Verify target database has summary stats preserved val targetSession = target.queries.selectSessionById("session-polar-1").executeAsOne() assertEquals(142L, targetSession.avgHeartRateBpm) assertEquals(168L, targetSession.maxHeartRateBpm) @@ -341,18 +339,60 @@ class DataBackupCompletenessTest { assertEquals(32.5f, targetSession.maxTorsoPitchDeg?.toFloat()) val targetMetrics = target.queries.selectMetricsBySession("session-polar-1").executeAsList() - assertEquals(1, targetMetrics.size) - assertEquals(145L, targetMetrics[0].heartRateBpm) - assertEquals(100L, targetMetrics[0].chestAccXMg) - + assertEquals(0, targetMetrics.size) val targetBeats = target.queries.selectPolarBeatSamplesBySession("session-polar-1").executeAsList() - assertEquals(1, targetBeats.size) - assertEquals(740.0, targetBeats[0].rrMs) - assertEquals(145L, targetBeats[0].hrBpm) - + assertEquals(0, targetBeats.size) val targetEcgs = target.queries.selectPolarEcgSegmentsBySession("session-polar-1").executeAsList() - assertEquals(1, targetEcgs.size) - assertEquals("100,105,110", targetEcgs[0].samplesUvCsv) + 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) } // ---- restore resets settings-held sync state ---- 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 index 23971aa45..99b09e153 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -362,7 +362,8 @@ class PolarH10BleManager( val avgHr = if (snapshot.hrs.isNotEmpty()) snapshot.hrs.average().roundToInt() else null val maxHr = snapshot.hrs.maxOrNull() val minHr = snapshot.hrs.minOrNull() - val avgRr = if (snapshot.rrs.isNotEmpty()) snapshot.rrs.average().toFloat() else null + 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( avgHeartRateBpm = avgHr, @@ -1174,14 +1175,25 @@ class PolarH10BleManager( scanJob = null stateObserverJob?.cancel() stateObserverJob = null - cleanupSessionTelemetry() val p = peripheral - peripheral = null if (p != null) { scope.launch { - try { p.disconnect() } catch (_: Exception) {} + try { + if (isEcgStreaming) { + try { stopEcgStream() } catch (_: Exception) {} + } + if (isAccStreaming) { + try { stopAccStream() } catch (_: Exception) {} + } + p.disconnect() + } catch (_: Exception) {} } } + cleanupExistingConnection() + } + + suspend fun cleanup() { + disconnect() } internal fun isPolarH10Advertisement(adv: Advertisement): Boolean { 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 31b911589..f2848bd6e 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 @@ -22,6 +22,8 @@ import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.domain.model.currentTimeMillis import com.devil.phoenixproject.domain.model.generateUUID import com.devil.phoenixproject.domain.onerepmax.WorkoutVelocityPoint +import com.devil.phoenixproject.util.PolarTelemetryUtils +import kotlin.math.abs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow @@ -1692,7 +1694,28 @@ class SqlDelightWorkoutRepository( override suspend fun getPolarBeatSamplesBySession(sessionId: String): List = withContext(Dispatchers.IO) { - queries.selectPolarBeatSamplesBySession(sessionId).executeAsList() + val samples = queries.selectPolarBeatSamplesBySession(sessionId).executeAsList() + if (samples.size >= 2) { + val session = queries.selectSessionById(sessionId).executeAsOneOrNull() + if (session != null && session.rmssdMs != null && session.rmssdMs > 80.0) { + val filteredRmssd = PolarTelemetryUtils.calculateRmssdMs(samples.map { it.rrMs }) + if (filteredRmssd != null && abs(session.rmssdMs - filteredRmssd) > 1.0) { + queries.updateSessionPolarSummary( + id = session.id, + avgHeartRateBpm = session.avgHeartRateBpm, + maxHeartRateBpm = session.maxHeartRateBpm, + minHeartRateBpm = session.minHeartRateBpm, + avgRrIntervalMs = session.avgRrIntervalMs, + rmssdMs = filteredRmssd, + hrr60Bpm = session.hrr60Bpm, + hrr300Bpm = session.hrr300Bpm, + maxTorsoPitchDeg = session.maxTorsoPitchDeg, + maxTorsoRollDeg = session.maxTorsoRollDeg, + ) + } + } + } + samples } override suspend fun getPolarEcgSegmentsBySession(sessionId: String): List = diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt index 1acc53741..61216cf82 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt @@ -36,6 +36,7 @@ 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.util.PolarTelemetryUtils import com.devil.phoenixproject.database.MetricSample import com.devil.phoenixproject.database.PolarBeatSample import com.devil.phoenixproject.database.PolarEcgSegment @@ -97,13 +98,35 @@ fun PolarSessionAnalyticsCard( } ?: resolvedSessionId?.let { polarBleManager.getSessionSummary(it) } } - val summary = latestSummary ?: fallbackSummary + val rawSummary = latestSummary ?: 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()) } + val computedRmssdFromBeats = remember(beatSamples) { + if (beatSamples.size >= 2) { + PolarTelemetryUtils.calculateRmssdMs(beatSamples.map { it.rrMs })?.toFloat() + } else null + } + + val computedAvgRrFromBeats = remember(beatSamples) { + val validRrs = beatSamples.map { it.rrMs }.filter { it in 250.0..2500.0 } + if (validRrs.isNotEmpty()) validRrs.average().toFloat() else null + } + + val summary = remember(rawSummary, computedRmssdFromBeats, computedAvgRrFromBeats) { + if (rawSummary == null && computedRmssdFromBeats == null && computedAvgRrFromBeats == null) null + else { + val base = rawSummary ?: PolarSessionSummary() + base.copy( + rmssdMs = computedRmssdFromBeats ?: rawSummary?.rmssdMs, + avgRrIntervalMs = computedAvgRrFromBeats ?: rawSummary?.avgRrIntervalMs, + ) + } + } + LaunchedEffect(resolvedSessionId) { if (resolvedSessionId != null) { try { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtils.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtils.kt index 31da25c41..d3e190977 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtils.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtils.kt @@ -1,6 +1,7 @@ package com.devil.phoenixproject.presentation.util import com.devil.phoenixproject.domain.model.PolarRrBeat +import kotlin.math.abs import kotlin.math.roundToInt import kotlin.math.sqrt @@ -9,9 +10,9 @@ import kotlin.math.sqrt * over a sliding time window (default 60 seconds, min 30 seconds recommended). * * Successive differences are computed on adjacent [PolarRrBeat] entries that fall within - * [currentTimeMs - windowMs, currentTimeMs]. Beats separated by excessive time gaps (> 3500ms) - * or with non-physiological intervals (< 250ms or > 2500ms) are excluded from difference pairs - * to prevent dropped-beat artifact inflation. + * [currentTimeMs - windowMs, currentTimeMs]. Beats separated by excessive time gaps (> 3500ms), + * with non-physiological intervals (< 250ms or > 2500ms), or with relative RR-delta > 25% are + * excluded from difference pairs to prevent dropped-beat artifact inflation. * * @param beats Ordered list of [PolarRrBeat] events. * @param windowMs Time window in milliseconds (default 60,000ms = 60s). @@ -47,7 +48,12 @@ fun computeRollingRmssd( continue } - val diff = b2.rrIntervalMs - b1.rrIntervalMs + val diff = abs(b2.rrIntervalMs - b1.rrIntervalMs) + // 25% relative RR-delta artifact filter + if (diff > maxOf(b1.rrIntervalMs, b2.rrIntervalMs) * 0.25) { + continue + } + sumSquaredDiffs += diff * diff count++ } 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 b29f0db6e..1c864351f 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 @@ -1461,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 b85840965..5ce629d8b 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt @@ -705,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). " } 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 4bdb5de24..364265ffd 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt @@ -444,16 +444,22 @@ 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() - for (session in sessions) { - val sessionMetrics = queries.selectMetricsBySession(session.id).executeAsList() - if (includeRawTelemetryInBackups) { + if (includeRawTelemetryInBackups) { + for (session in sessions) { + val sessionMetrics = queries.selectMetricsBySession(session.id).executeAsList() metrics.addAll(sessionMetrics) - } else { - metrics.addAll(sessionMetrics.filter { it.heartRateBpm != null || it.chestAccXMg != null }) } } - val polarBeatSamples = queries.selectAllBackupPolarBeatSamples().executeAsList() - val polarEcgSegments = queries.selectAllBackupPolarEcgSegments().executeAsList() + val polarBeatSamples = if (includeRawTelemetryInBackups) { + queries.selectAllBackupPolarBeatSamples().executeAsList() + } else { + emptyList() + } + val polarEcgSegments = if (includeRawTelemetryInBackups) { + queries.selectAllBackupPolarEcgSegments().executeAsList() + } else { + emptyList() + } // Full backups retain routine tombstones because historical telemetry keeps // foreign-key links to the original routine and routine exercise IDs. @@ -3155,23 +3161,20 @@ abstract class BaseDataBackupManager( writer.write("\"metricSamples\":[") var metricIndex = 0L var firstMetric = true - // Raw telemetry is opt-in (F-033); but metrics with Polar data are always included. - for (session in sessions) { - val sessionMetrics = queries.selectMetricsBySession(session.id).executeAsList() - val filteredMetrics = if (includeRawTelemetryInBackups) { - sessionMetrics - } else { - sessionMetrics.filter { it.heartRateBpm != null || it.chestAccXMg != null } - } - for (metric in filteredMetrics) { - if (!firstMetric) writer.write(",") - firstMetric = false - writer.write(json.encodeToString(MetricSampleBackup.serializer(), mapMetricToBackup(metric))) - metricIndex++ - } - if (filteredMetrics.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("],") @@ -3234,11 +3237,19 @@ abstract class BaseDataBackupManager( }) writer.write(",") - val polarBeatSamples = queries.selectAllBackupPolarBeatSamples().executeAsList() + val polarBeatSamples = if (includeRawTelemetryInBackups) { + queries.selectAllBackupPolarBeatSamples().executeAsList() + } else { + emptyList() + } writeJsonArray(writer, "polarBeatSamples", polarBeatSamples.map { json.encodeToString(PolarBeatSampleBackup.serializer(), mapPolarBeatSampleToBackup(it)) }) writer.write(",") - val polarEcgSegments = queries.selectAllBackupPolarEcgSegments().executeAsList() + val polarEcgSegments = if (includeRawTelemetryInBackups) { + queries.selectAllBackupPolarEcgSegments().executeAsList() + } else { + emptyList() + } writeJsonArray(writer, "polarEcgSegments", polarEcgSegments.map { json.encodeToString(PolarEcgSegmentBackup.serializer(), mapPolarEcgSegmentToBackup(it)) }) writer.write(",") @@ -4296,14 +4307,21 @@ abstract class BaseDataBackupManager( val session = queries.selectSessionById(sessionId).executeAsOneOrNull() ?: return@withContext Result.failure(Exception("Session not found: $sessionId")) - val sessionMetrics = queries.selectMetricsBySession(sessionId).executeAsList() val metrics = if (includeRawTelemetryInBackups) { - sessionMetrics + queries.selectMetricsBySession(sessionId).executeAsList() } else { - sessionMetrics.filter { it.heartRateBpm != null || it.chestAccXMg != null } + emptyList() + } + val beatSamples = if (includeRawTelemetryInBackups) { + queries.selectPolarBeatSamplesBySession(sessionId).executeAsList() + } else { + emptyList() + } + val ecgSegments = if (includeRawTelemetryInBackups) { + queries.selectPolarEcgSegmentsBySession(sessionId).executeAsList() + } else { + emptyList() } - val beatSamples = queries.selectPolarBeatSamplesBySession(sessionId).executeAsList() - val ecgSegments = queries.selectPolarEcgSegmentsBySession(sessionId).executeAsList() val completedSets = queries.selectCompletedSetsBySession(sessionId).executeAsList() val sessions = listOf(session) @@ -4383,15 +4401,12 @@ abstract class BaseDataBackupManager( val allBeatSamples = mutableListOf() val allEcgSegments = mutableListOf() for (session in sessions) { - val sessionMetrics = queries.selectMetricsBySession(session.id).executeAsList() if (includeRawTelemetryInBackups) { - allMetrics.addAll(sessionMetrics) - } else { - allMetrics.addAll(sessionMetrics.filter { it.heartRateBpm != null || it.chestAccXMg != null }) + 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()) - allBeatSamples.addAll(queries.selectPolarBeatSamplesBySession(session.id).executeAsList()) - allEcgSegments.addAll(queries.selectPolarEcgSegmentsBySession(session.id).executeAsList()) } val routineNowMs = KmpUtils.currentTimeMillis() diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt index 988f51872..703e469a6 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt @@ -101,15 +101,24 @@ object PolarTelemetryUtils { /** * Compute Root Mean Square of Successive Differences (RMSSD) in milliseconds from R-R intervals. + * Applies physiological bounds (250.0..2500.0 ms) and a 25% relative RR-delta artifact filter + * (abs(rr2 - rr1) <= maxOf(rr1, rr2) * 0.25) to reject missed-beat spikes and noise. */ fun calculateRmssdMs(rrs: List): Double? { if (rrs.size < 2) return null var sumSquares = 0.0 + var validDiffs = 0 for (i in 1 until rrs.size) { - val diff = rrs[i] - rrs[i - 1] + val rr1 = rrs[i - 1] + val rr2 = rrs[i] + if (rr1 !in 250.0..2500.0 || rr2 !in 250.0..2500.0) continue + val diff = abs(rr2 - rr1) + if (diff > maxOf(rr1, rr2) * 0.25) continue sumSquares += diff * diff + validDiffs++ } - return sqrt(sumSquares / (rrs.size - 1)) + if (validDiffs == 0) return null + return sqrt(sumSquares / validDiffs) } } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtilsTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtilsTest.kt index b29a114a0..f7c3f099e 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtilsTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtilsTest.kt @@ -64,4 +64,25 @@ class PolarTelemetryUtilsTest { // 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/util/PolarTelemetryUtilsTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtilsTest.kt index 96b3a7f84..0ffcbfd30 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtilsTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtilsTest.kt @@ -131,4 +131,24 @@ class PolarTelemetryUtilsTest { 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/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineCompleteScreen.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineCompleteScreen.kt index f3337171e..f08ea5356 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 @@ -253,12 +253,12 @@ fun RoutineCompleteScreen(navController: NavController, viewModel: MainViewModel } } } - - // Polar H10 Session Analytics & 5-minute recovery cooldown - PolarSessionAnalyticsCard( - modifier = Modifier.padding(top = Spacing.medium), - ) } + + // Polar H10 Session Analytics & 5-minute recovery cooldown + PolarSessionAnalyticsCard( + modifier = Modifier.fillMaxWidth(), + ) } // Reserved Done footer — sibling of the scroll body, outside every From eb9ea71ef9b8cbd9ce7df4b7a7a68824609e460b Mon Sep 17 00:00:00 2001 From: StuGotz Date: Tue, 20 Oct 2026 17:05:42 -0500 Subject: [PATCH 11/25] fix(local): consolidate migration 58 statements and fix syntax in MigrationStatements --- .../phoenixproject/data/local/MigrationStatements.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) 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 37b4a8f9a..0fe4abaf9 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 @@ -1,4 +1,4 @@ -package com.devil.phoenixproject.data.local +package com.devil.phoenixproject.data.local import app.cash.sqldelight.db.SqlDriver @@ -1300,17 +1300,14 @@ 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) and + // Polar H10 multi-tier telemetry persistence. 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", "ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_local_generation INTEGER NOT NULL DEFAULT 0", "ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_server_revision INTEGER NOT NULL DEFAULT 0", "ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_dirty INTEGER NOT NULL DEFAULT 0", - - // Migration 58: Polar H10 multi-tier telemetry persistence. Mirrors 58.sqm exactly. - 58 -> listOf( "ALTER TABLE MetricSample ADD COLUMN heartRateBpm INTEGER", "ALTER TABLE MetricSample ADD COLUMN rrIntervalMs REAL", "ALTER TABLE MetricSample ADD COLUMN torsoPitchDeg REAL", From c8cbe6703e7243715a8c64f16c87266ea962f56c Mon Sep 17 00:00:00 2001 From: StuGotz Date: Tue, 20 Oct 2026 18:34:17 -0500 Subject: [PATCH 12/25] fix(polar): ignore and drop telemetry streams upon strap removal or disconnection --- .../data/ble/PolarH10BleManager.kt | 118 +++++++++++++++++- .../manager/ActiveSessionEngine.kt | 1 + .../manager/DefaultWorkoutSessionManager.kt | 8 +- .../presentation/viewmodel/MainViewModel.kt | 14 +++ .../data/ble/PolarH10BleManagerTest.kt | 112 +++++++++++++++++ 5 files changed, 246 insertions(+), 7 deletions(-) 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 index 99b09e153..a98920f4e 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -295,6 +295,15 @@ class PolarH10BleManager( var hrr300Persisted = false while (isActive && (timeSource() - cooldownStartMs) < 300_000L) { delay(1000L) + if (peripheral != null && !_isConnected.value) { + log.i { "Polar H10 disconnected during recovery cooldown: terminating recovery window" } + cancelCooldown() + break + } + checkPacketAbsenceTimeout(timeSource()) + if (!_isCooldownActive.value) { + break + } val elapsedMs = timeSource() - cooldownStartMs val remainingSec = (300 - (elapsedMs / 1000)).toInt().coerceAtLeast(0) _cooldownSecondsRemaining.value = remainingSec @@ -325,6 +334,9 @@ class PolarH10BleManager( } _latestSessionSummary.value = getSessionSummary(targetSessionId) } + if (!_isCooldownActive.value) { + return@launch + } val finalHr = _hrMeasurement.value?.hr if (!hrr300Persisted && finalHr != null && endHeartRate != null) { val drop = (endHeartRate!! - finalHr).coerceAtLeast(0) @@ -399,9 +411,22 @@ class PolarH10BleManager( } // ===== 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) { + _connectionState.value = ConnectionState.Connected("Polar H10", "00:11:22:33:44:55") + } else { + _connectionState.value = ConnectionState.Disconnected + cleanupSessionTelemetry() + } + } + private val _scannedDevices = MutableStateFlow>(emptyList()) val scannedDevices: StateFlow> = _scannedDevices.asStateFlow() @@ -634,9 +659,11 @@ class PolarH10BleManager( log.d { "Polar H10 state: $state" } when (state) { is State.Connecting -> { + _isConnected.value = false _connectionState.value = ConnectionState.Connecting } is State.Connected -> { + _isConnected.value = true _connectionState.value = ConnectionState.Connected( deviceName = device.name, deviceAddress = device.address, @@ -650,9 +677,10 @@ class PolarH10BleManager( onDeviceConnected(p) } is State.Disconnecting -> { - // Transient disconnecting state + _isConnected.value = false } is State.Disconnected -> { + _isConnected.value = false _connectionState.value = ConnectionState.Disconnected cleanupSessionTelemetry() } @@ -669,6 +697,7 @@ class PolarH10BleManager( } catch (e: Exception) { e.rethrowIfCancellation() log.e(e) { "Failed to connect to Polar H10: ${e.message}" } + _isConnected.value = false cleanupExistingConnection() _connectionState.value = ConnectionState.Error("Connection failed: ${e.message}", e) Result.failure(e) @@ -677,6 +706,7 @@ class PolarH10BleManager( private suspend fun onDeviceConnected(p: Peripheral) { log.i { "Configuring Polar H10 connection parameters and services" } + startWatchdog() // 1. Request MTU 232 if supported try { @@ -707,6 +737,7 @@ class PolarH10BleManager( log.d { "Battery notification non-fatal: ${e.message}" } } .collect { bytes -> + if (p.state.value !is State.Connected) return@collect parsePolarBatteryLevel(bytes)?.let { _batteryLevel.value = it } } } catch (e: Exception) { @@ -742,7 +773,19 @@ class PolarH10BleManager( 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) { + log.w { "Polar H10 strap removed or zero HR detected (contact=${measurement.contactDetected}, hr=${measurement.hr}). Dropping telemetry." } + clearCachedTelemetry() + if (_isCooldownActive.value) { + log.i { "Terminating recovery cooldown due to strap removal" } + cancelCooldown() + } + return@collect + } + + lastValidPacketTimestampMs = timeSource() _hrMeasurement.value = measurement _hrMeasurements.emit(measurement) @@ -763,6 +806,17 @@ class PolarH10BleManager( * if gaps or drift exceed 5 seconds. */ internal fun processRrIntervals(measurement: PolarHrMeasurement) { + if (peripheral != null && peripheral?.state?.value !is State.Connected) { + return + } + if (!measurement.contactDetected || measurement.hr <= 0) { + clearCachedTelemetry() + if (_isCooldownActive.value) { + cancelCooldown() + } + return + } + lastValidPacketTimestampMs = timeSource() _hrMeasurement.value = measurement val rrs = measurement.rrIntervalsMs @@ -876,13 +930,24 @@ class PolarH10BleManager( log.w { "PMD Data observe error: ${e.message}" } } .collect { data -> + if (p.state.value !is State.Connected) return@collect val measurement = parsePolarPmdData(data) ?: return@collect - _pmdMeasurement.tryEmit(measurement) when (measurement) { is PolarPmdMeasurement.Ecg -> { + val samples = measurement.samplesMicrovolts + val isFlatlineOrZero = samples.isEmpty() || + samples.all { it == 0 } || + (samples.isNotEmpty() && samples.all { it == samples[0] }) + if (isFlatlineOrZero) { + log.w { "Polar H10 ECG flatline or zero lead-off detected. Dropping ECG segment." } + _latestEcg.value = null + return@collect + } + lastValidPacketTimestampMs = timeSource() + _pmdMeasurement.tryEmit(measurement) _latestEcg.value = measurement _ecgStream.tryEmit(measurement) - val currentSid = activeSessionId + val currentSid = activeSessionId if (currentSid != null && database != null) { try { val nowMs = timeSource() @@ -900,6 +965,9 @@ class PolarH10BleManager( } } is PolarPmdMeasurement.Acc -> { + if (measurement.samples.isEmpty()) return@collect + lastValidPacketTimestampMs = timeSource() + _pmdMeasurement.tryEmit(measurement) _latestAcc.value = measurement _accStream.tryEmit(measurement) for (s in measurement.samples) { @@ -1110,10 +1178,39 @@ class PolarH10BleManager( } } + // ===== Watchdog for Packet Absence (> 3s) ===== + private var watchdogJob: Job? = null + private var lastValidPacketTimestampMs: Long = 0L + + internal fun checkPacketAbsenceTimeout(currentTimestampMs: Long = timeSource()) { + 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." } + clearCachedTelemetry() + if (_isCooldownActive.value) { + log.i { "Terminating recovery cooldown due to packet absence timeout (>3s)" } + cancelCooldown() + } + } + } + } + + private fun startWatchdog() { + watchdogJob?.cancel() + lastValidPacketTimestampMs = timeSource() + watchdogJob = scope.launch { + while (isActive && _isConnected.value) { + delay(500L) + checkPacketAbsenceTimeout(timeSource()) + } + } + } + /** * Disconnect from the Polar H10 device and clean up all active telemetry streams. */ suspend fun disconnect() { + _isConnected.value = false val p = peripheral if (p != null) { log.i { "Disconnecting Polar H10" } @@ -1145,9 +1242,21 @@ class PolarH10BleManager( pmdDataJob?.cancel() pmdDataJob = null + watchdogJob?.cancel() + watchdogJob = null + isEcgStreaming = false isAccStreaming = false + _isConnected.value = false + clearCachedTelemetry() + + if (_isCooldownActive.value) { + cancelCooldown() + } + } + + internal fun clearCachedTelemetry() { lastBeatTimestampMs = null _batteryLevel.value = null _hrMeasurement.value = null @@ -1155,8 +1264,6 @@ class PolarH10BleManager( _latestEcg.value = null _latestAcc.value = null - cooldownJob?.cancel() - cooldownJob = null accFilter.reset() _torsoPitchDeg.value = null _torsoRollDeg.value = null @@ -1171,6 +1278,7 @@ class PolarH10BleManager( } fun shutdown() { + _isConnected.value = false scanJob?.cancel() scanJob = null stateObserverJob?.cancel() 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 653b22621..31fc4f4fa 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 @@ -5983,6 +5983,7 @@ class ActiveSessionEngine( */ private fun collectMetricForHistory(metric: WorkoutMetric) { 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( 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 6604ba116..638f7713c 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 @@ -532,7 +532,9 @@ class DefaultWorkoutSessionManager( polarBleManager.updateSessionPhase(phase) } is WorkoutState.Completed, WorkoutState.RoutineComplete -> { - polarBleManager.endSessionRecording(coordinator.currentSessionId) + if (polarBleManager.isConnected.value) { + polarBleManager.endSessionRecording(coordinator.currentSessionId) + } } is WorkoutState.SetSummary -> { polarBleManager.updateSessionPhase("REST") @@ -550,7 +552,9 @@ class DefaultWorkoutSessionManager( try { coordinator.routineFlowState.collect { flowState -> if (flowState is RoutineFlowState.Complete) { - polarBleManager.endSessionRecording(coordinator.currentSessionId) + if (polarBleManager.isConnected.value) { + polarBleManager.endSessionRecording(coordinator.currentSessionId) + } } } } catch (e: Exception) { 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 1c864351f..839930dfe 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 @@ -547,6 +547,19 @@ class MainViewModel( private val polarBleManager: PolarH10BleManager? = null, ) : ViewModel() { + init { + polarBleManager?.let { polar -> + viewModelScope.launch { + polar.isConnected.collect { connected -> + if (!connected && polar.isCooldownActive.value) { + Logger.w { "Polar H10 disconnected during recovery cooldown: terminating recovery window" } + polar.cancelCooldown() + } + } + } + } + } + // Shared haptic events flow - created here, passed to both GamificationManager and WorkoutSessionManager private val _hapticEvents = MutableSharedFlow( extraBufferCapacity = 32, @@ -836,6 +849,7 @@ class MainViewModel( val scannedDevices: StateFlow> get() = bleConnectionManager.scannedDevices val connectionError: StateFlow get() = bleConnectionManager.connectionError val connectionLostDuringWorkout: StateFlow get() = bleConnectionManager.connectionLostDuringWorkout + val isPolarConnected: StateFlow? get() = polarBleManager?.isConnected val machineSafetyUiState: StateFlow = machineSafetyCoordinator.uiState fun startScanning() = bleConnectionManager.startScanning() 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 index 564aba0a7..1f3d17d0a 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt @@ -367,6 +367,118 @@ class PolarH10BleManagerTest { 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) + assertEquals(100_000L, manager.rrClockTimestampMs.value) + + // 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") + assertNull(manager.rrClockTimestampMs.value, "rrClockTimestampMs should be cleared upon strap removal") + + collectJob.cancel() + testScope.cancel() + } + + @Test + fun `strap removal or disconnection during cooldown immediately terminates recovery window`() { + var simulatedTime = 100_000L + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + 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())) + + assertFalse(manager.isCooldownActive.value, "Cooldown must be terminated upon strap removal") + assertEquals(0, manager.cooldownSecondsRemaining.value) + + // Reactivate cooldown and verify disconnection terminates it + 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() + } } From 5cde73d5d014187f087116a14cecaf94e30efd35 Mon Sep 17 00:00:00 2001 From: StuGotz Date: Tue, 20 Oct 2026 18:59:05 -0500 Subject: [PATCH 13/25] fix(polar): drop and suppress telemetry noise instantly when electrode contact is lost --- .../data/ble/PolarH10BleManager.kt | 191 +++++++++++------- .../components/PolarH10WorkoutStrip.kt | 13 +- .../data/ble/PolarH10BleManagerTest.kt | 126 ++++++++++++ 3 files changed, 260 insertions(+), 70 deletions(-) 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 index a98920f4e..48e79ea67 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -84,6 +84,8 @@ class PolarH10BleManager( 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 } // ===== Remembered Device & Auto-Reconnect ===== @@ -431,6 +433,27 @@ class PolarH10BleManager( 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() + } + } + } + private val _batteryLevel = MutableStateFlow(null) val batteryLevel: StateFlow = _batteryLevel.asStateFlow() @@ -738,7 +761,17 @@ class PolarH10BleManager( } .collect { bytes -> if (p.state.value !is State.Connected) return@collect - parsePolarBatteryLevel(bytes)?.let { _batteryLevel.value = it } + 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() @@ -775,16 +808,18 @@ class PolarH10BleManager( .collect { data -> if (p.state.value !is State.Connected) return@collect val measurement = parsePolarHrMeasurement(data) ?: return@collect - if (!measurement.contactDetected || measurement.hr <= 0) { - log.w { "Polar H10 strap removed or zero HR detected (contact=${measurement.contactDetected}, hr=${measurement.hr}). Dropping telemetry." } + 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." } + _isContactDetected.value = false clearCachedTelemetry() if (_isCooldownActive.value) { - log.i { "Terminating recovery cooldown due to strap removal" } + log.i { "Terminating recovery cooldown due to strap removal or critical battery" } cancelCooldown() } return@collect } + _isContactDetected.value = true lastValidPacketTimestampMs = timeSource() _hrMeasurement.value = measurement _hrMeasurements.emit(measurement) @@ -809,13 +844,15 @@ class PolarH10BleManager( if (peripheral != null && peripheral?.state?.value !is State.Connected) { return } - if (!measurement.contactDetected || measurement.hr <= 0) { + if (!measurement.contactDetected || measurement.hr <= 0 || isBatteryCritical()) { + _isContactDetected.value = false clearCachedTelemetry() if (_isCooldownActive.value) { cancelCooldown() } return } + _isContactDetected.value = true lastValidPacketTimestampMs = timeSource() _hrMeasurement.value = measurement val rrs = measurement.rrIntervalsMs @@ -932,69 +969,7 @@ class PolarH10BleManager( .collect { data -> if (p.state.value !is State.Connected) return@collect val measurement = parsePolarPmdData(data) ?: return@collect - when (measurement) { - is PolarPmdMeasurement.Ecg -> { - val samples = measurement.samplesMicrovolts - val isFlatlineOrZero = samples.isEmpty() || - samples.all { it == 0 } || - (samples.isNotEmpty() && samples.all { it == samples[0] }) - if (isFlatlineOrZero) { - log.w { "Polar H10 ECG flatline or zero lead-off detected. Dropping ECG segment." } - _latestEcg.value = null - return@collect - } - lastValidPacketTimestampMs = timeSource() - _pmdMeasurement.tryEmit(measurement) - _latestEcg.value = measurement - _ecgStream.tryEmit(measurement) - val currentSid = activeSessionId - if (currentSid != null && database != null) { - try { - val nowMs = timeSource() - val csv = measurement.samplesMicrovolts.joinToString(",") - database.phoenixDatabaseQueries.insertPolarEcgSegment( - sessionId = currentSid, - sensorTimestampNs = measurement.sensorTimestampNs, - wallClockTimestampMs = nowMs, - phase = currentPhase, - samplesUvCsv = csv, - ) - } catch (e: Exception) { - log.w { "Failed to insert PolarEcgSegment: ${e.message}" } - } - } - } - is PolarPmdMeasurement.Acc -> { - if (measurement.samples.isEmpty()) return@collect - lastValidPacketTimestampMs = timeSource() - _pmdMeasurement.tryEmit(measurement) - _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) { - 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 - } - } - } - } - } - } + processPmdMeasurement(measurement) } } catch (e: Exception) { e.rethrowIfCancellation() @@ -1012,6 +987,83 @@ class PolarH10BleManager( true } + internal fun processPmdMeasurement(measurement: PolarPmdMeasurement) { + 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})" } + clearCachedTelemetry() + return + } + + when (measurement) { + is PolarPmdMeasurement.Ecg -> { + 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 ECG segment and clearing telemetry." } + _isContactDetected.value = false + clearCachedTelemetry() + return + } + lastValidPacketTimestampMs = timeSource() + _pmdMeasurement.tryEmit(measurement) + _latestEcg.value = measurement + _ecgStream.tryEmit(measurement) + val currentSid = activeSessionId + if (currentSid != null && database != null) { + try { + val nowMs = timeSource() + val csv = measurement.samplesMicrovolts.joinToString(",") + database.phoenixDatabaseQueries.insertPolarEcgSegment( + sessionId = currentSid, + sensorTimestampNs = measurement.sensorTimestampNs, + wallClockTimestampMs = nowMs, + phase = currentPhase, + samplesUvCsv = csv, + ) + } catch (e: Exception) { + log.w { "Failed to insert PolarEcgSegment: ${e.message}" } + } + } + } + is PolarPmdMeasurement.Acc -> { + if (measurement.samples.isEmpty()) return + lastValidPacketTimestampMs = timeSource() + _pmdMeasurement.tryEmit(measurement) + _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) { + 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, @@ -1249,6 +1301,7 @@ class PolarH10BleManager( isAccStreaming = false _isConnected.value = false + _batteryLevel.value = null clearCachedTelemetry() if (_isCooldownActive.value) { @@ -1257,8 +1310,8 @@ class PolarH10BleManager( } internal fun clearCachedTelemetry() { + _isContactDetected.value = false lastBeatTimestampMs = null - _batteryLevel.value = null _hrMeasurement.value = null _rrClockTimestampMs.value = null _latestEcg.value = null diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt index 7fbe632c6..16502e1c5 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt @@ -94,6 +94,8 @@ private fun PolarH10WorkoutStripContent( ) { 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() } @@ -103,6 +105,16 @@ private fun PolarH10WorkoutStripContent( // 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() @@ -169,7 +181,6 @@ private fun PolarH10WorkoutStripContent( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 8.dp), ) { - val hasContact = hrMeasurement?.contactDetected == true Icon( Icons.Default.Favorite, contentDescription = "Polar Heart Rate", 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 index 1f3d17d0a..fe608917c 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt @@ -1,7 +1,9 @@ package com.devil.phoenixproject.data.ble 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.util.BleConstants import com.juul.kable.Advertisement @@ -10,6 +12,7 @@ import com.juul.kable.ManufacturerData 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 @@ -479,6 +482,129 @@ class PolarH10BleManagerTest { 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 triggers contact loss and clears telemetry`() { + 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) + + // Deliver railed open-circuit noise (>= 15,000 uV) + manager.processPmdMeasurement( + PolarPmdMeasurement.Ecg( + sensorTimestampNs = 3_000_000L, + samplesMicrovolts = listOf(100, 16_000, 200), + ) + ) + + assertFalse(manager.isContactDetected.value, "Railed ECG must trip contact loss") + assertNull(manager.latestEcg.value, "Railed ECG must be discarded") + assertNull(manager.hrMeasurement.value, "Telemetry must be cleared upon ECG railing") + + testScope.cancel() + } + + @Test + fun `ECG flatline triggers contact loss and drops packet`() { + 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), + ) + ) + + assertFalse(manager.isContactDetected.value, "Flatline ECG must trip contact loss") + assertNull(manager.latestEcg.value, "Flatline ECG packet must be dropped") + + 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() + } } From 1ed0af46dcda7ae6e8df5d48045804db1fd23fba Mon Sep 17 00:00:00 2001 From: StuGotz Date: Tue, 20 Oct 2026 22:47:33 -0500 Subject: [PATCH 14/25] fix(polar): resolve PR review findings including FK telemetry persistence and RMSSD deduplication --- .../data/ble/PolarH10PersistenceTest.kt | 202 +++++++++++++ .../data/ble/PolarH10BleManager.kt | 270 +++++++++++++++--- .../repository/SqlDelightWorkoutRepository.kt | 23 +- .../components/PolarH10SettingsCard.kt | 36 --- .../components/PolarH10WorkoutStrip.kt | 5 +- .../components/PolarSessionAnalyticsCard.kt | 72 +++-- .../manager/ActiveSessionEngine.kt | 2 + .../manager/DefaultWorkoutSessionManager.kt | 5 + .../presentation/util/PolarTelemetryUtils.kt | 56 +--- .../devil/phoenixproject/util/BleConstants.kt | 14 +- .../phoenixproject/util/DataBackupManager.kt | 22 +- .../util/PolarTelemetryUtils.kt | 57 ++-- ...t.kt => PolarRollingTelemetryUtilsTest.kt} | 2 +- .../util/PolarTelemetryUtilsTest.kt | 40 +-- .../screen/ActiveWorkoutScreen.kt | 14 +- .../presentation/screen/HistoryTab.kt | 2 +- 16 files changed, 577 insertions(+), 245 deletions(-) create mode 100644 shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10PersistenceTest.kt rename shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/{PolarTelemetryUtilsTest.kt => PolarRollingTelemetryUtilsTest.kt} (98%) 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..39011ff62 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10PersistenceTest.kt @@ -0,0 +1,202 @@ +package com.devil.phoenixproject.data.ble + +import com.devil.phoenixproject.database.PhoenixDatabase +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 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( + database = database, + scope = testScope, + timeSource = { simulatedTime }, + ) + 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 insert directly to DB without buffering + manager.triggerCooldown(sessionId) + val cooldownBeat = PolarHrMeasurement( + hr = 130, + contactDetected = true, + rrIntervalsMs = listOf(860.0), + ) + manager.processRrIntervals(cooldownBeat) + + 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( + database = database, + 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()) + } +} 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 index 48e79ea67..fb1f2977c 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -20,6 +20,7 @@ import com.juul.kable.WriteType import com.russhwolf.settings.Settings import kotlin.math.abs import kotlin.math.roundToInt +import kotlin.time.TimeSource import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -51,6 +52,7 @@ import kotlinx.coroutines.withTimeoutOrNull * 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, @@ -76,11 +78,15 @@ class PolarH10BleManager( private val settings: Settings? = null, private val database: PhoenixDatabase? = null, private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), - private val timeSource: () -> Long = ::currentTimeMillis, + private val timeSource: () -> Long = DEFAULT_WALL_CLOCK, + monotonicTimeSource: (() -> Long)? = null, ) { 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" @@ -88,6 +94,10 @@ class PolarH10BleManager( const val ECG_RAIL_THRESHOLD_UV = 15_000 } + 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() @@ -134,6 +144,26 @@ class PolarH10BleManager( } // ===== Session Recording & Recovery State ===== + internal data class BufferedBeatSample( + 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?, + ) + + internal data class BufferedEcgSegment( + val sessionId: String, + val sensorTimestampNs: Long, + val wallClockTimestampMs: Long, + val phase: String, + val samplesUvCsv: String, + ) + private val sessionLock = reentrantLock() private var activeSessionId: String? = null private var currentPhase: String = "WARMUP" @@ -141,12 +171,16 @@ class PolarH10BleManager( private val sessionHrs = mutableListOf() private val sessionRrs = mutableListOf() private val recentRrs = ArrayDeque() + private val bufferedBeatSamples = mutableListOf() + private val bufferedEcgSegments = mutableListOf() + private val committedSessionIds = mutableSetOf() 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 val _isCooldownActive = MutableStateFlow(false) val isCooldownActive: StateFlow = _isCooldownActive.asStateFlow() @@ -187,12 +221,16 @@ class PolarH10BleManager( activeSessionId = sessionId currentPhase = phase sessionLock.withLock { + bufferedBeatSamples.clear() + bufferedEcgSegments.clear() + committedSessionIds.remove(sessionId) sessionHrs.clear() sessionRrs.clear() recentRrs.clear() maxTorsoPitch = null maxTorsoRoll = null } + lastBeatMonotonicMs = null endHeartRate = null hrr60 = null hrr300 = null @@ -204,6 +242,86 @@ class PolarH10BleManager( } } + /** + * Flush buffered telemetry records to the database within a transaction once the parent + * [WorkoutSession] row is committed. Subsequent records will insert directly into SQLite. + */ + fun flushSessionTelemetry(sessionId: String, fallbackSessionId: String? = null) { + val (beatsToFlush, ecgsToFlush) = sessionLock.withLock { + committedSessionIds.add(sessionId) + if (fallbackSessionId != null) { + committedSessionIds.add(fallbackSessionId) + } + activeSessionId = sessionId + val beats = bufferedBeatSamples.toList() + bufferedBeatSamples.clear() + val ecgs = bufferedEcgSegments.toList() + bufferedEcgSegments.clear() + Pair(beats, ecgs) + } + + if (database != null && (beatsToFlush.isNotEmpty() || ecgsToFlush.isNotEmpty())) { + try { + database.transaction { + for (b in beatsToFlush) { + database.phoenixDatabaseQueries.insertPolarBeatSample( + sessionId = sessionId, + timestampMs = b.timestampMs, + phase = b.phase, + hrBpm = b.hrBpm, + rrMs = b.rrMs, + rmssdMs = b.rmssdMs, + torsoPitchDeg = b.torsoPitchDeg, + torsoRollDeg = b.torsoRollDeg, + dynamicAccMg = b.dynamicAccMg, + ) + } + for (seg in ecgsToFlush) { + database.phoenixDatabaseQueries.insertPolarEcgSegment( + sessionId = sessionId, + sensorTimestampNs = seg.sensorTimestampNs, + wallClockTimestampMs = seg.wallClockTimestampMs, + phase = seg.phase, + samplesUvCsv = seg.samplesUvCsv, + ) + } + } + log.i { "Flushed ${beatsToFlush.size} beat samples and ${ecgsToFlush.size} ECG segments for session $sessionId" } + } catch (e: Exception) { + log.e(e) { "Failed to flush buffered Polar telemetry for session $sessionId: ${e.message}" } + } + } + } + + /** + * Cleanly discards active recording buffers and resets session tracking if a set is cancelled. + */ + fun discardActiveRecording() { + sessionLock.withLock { + bufferedBeatSamples.clear() + bufferedEcgSegments.clear() + sessionHrs.clear() + sessionRrs.clear() + recentRrs.clear() + maxTorsoPitch = null + maxTorsoRoll = null + } + val targetSessionId = activeSessionId + if (targetSessionId != null) { + committedSessionIds.remove(targetSessionId) + } + lastBeatMonotonicMs = null + activeSessionId = 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) { if (phase == "WORKING_SET" || phase == "WARMUP") { if (_isCooldownActive.value || currentPhase == "POST_WORKOUT_COOLDOWN") { @@ -290,23 +408,23 @@ class PolarH10BleManager( _latestSessionSummary.value = getSessionSummary(targetSessionId) cooldownJob?.cancel() - val cooldownStartMs = timeSource() + val cooldownStartMs = monotonicClock() cooldownStartTimestampMs = cooldownStartMs cooldownJob = scope.launch { var hrr60Persisted = false var hrr300Persisted = false - while (isActive && (timeSource() - cooldownStartMs) < 300_000L) { + while (isActive && (monotonicClock() - cooldownStartMs) < 300_000L) { delay(1000L) if (peripheral != null && !_isConnected.value) { log.i { "Polar H10 disconnected during recovery cooldown: terminating recovery window" } cancelCooldown() break } - checkPacketAbsenceTimeout(timeSource()) + checkPacketAbsenceTimeout(monotonicClock()) if (!_isCooldownActive.value) { break } - val elapsedMs = timeSource() - cooldownStartMs + val elapsedMs = monotonicClock() - cooldownStartMs val remainingSec = (300 - (elapsedMs / 1000)).toInt().coerceAtLeast(0) _cooldownSecondsRemaining.value = remainingSec @@ -380,6 +498,7 @@ class PolarH10BleManager( 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, @@ -679,6 +798,10 @@ class PolarH10BleManager( 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 -> { @@ -820,7 +943,7 @@ class PolarH10BleManager( } _isContactDetected.value = true - lastValidPacketTimestampMs = timeSource() + lastValidPacketTimestampMs = monotonicClock() _hrMeasurement.value = measurement _hrMeasurements.emit(measurement) @@ -853,14 +976,15 @@ class PolarH10BleManager( return } _isContactDetected.value = true - lastValidPacketTimestampMs = timeSource() + lastValidPacketTimestampMs = monotonicClock() _hrMeasurement.value = measurement val rrs = measurement.rrIntervalsMs - val nowMs = timeSource() + val nowMonoMs = monotonicClock() + val nowWallMs = wallClock() if (_isCooldownActive.value) { val startTs = cooldownStartTimestampMs - if (startTs != null && (nowMs - startTs) <= 30_000L) { + if (startTs != null && (nowMonoMs - startTs) <= 30_000L) { val peak = maxOf(endHeartRate ?: measurement.hr, measurement.hr) if (endHeartRate != peak) { endHeartRate = peak @@ -871,10 +995,11 @@ class PolarH10BleManager( if (rrs.isEmpty()) return val previousBeat = lastBeatTimestampMs + val previousBeatMono = lastBeatMonotonicMs - val startingTs: Long = if (previousBeat == null || (nowMs - previousBeat) > 5000L || previousBeat > nowMs + 5000L) { + val startingTs: Long = if (previousBeat == null || previousBeatMono == null || (nowMonoMs - previousBeatMono) > 5000L || (nowMonoMs - previousBeatMono) < -5000L) { val totalRrDurationMs = rrs.sum().toLong() - nowMs - totalRrDurationMs + nowWallMs - totalRrDurationMs } else { previousBeat } @@ -898,25 +1023,45 @@ class PolarH10BleManager( if (recentRrs.size > 30) recentRrs.removeFirst() PolarTelemetryUtils.calculateRmssdMs(recentRrs.toList()) } - try { - database?.phoenixDatabaseQueries?.insertPolarBeatSample( - sessionId = currentSid, - timestampMs = beat.timestampMs, - phase = currentPhase, - hrBpm = beat.hr.toLong(), - rrMs = beat.rrIntervalMs, - rmssdMs = currentRmssd, - torsoPitchDeg = _torsoPitchDeg.value, - torsoRollDeg = _torsoRollDeg.value, - dynamicAccMg = _dynamicAccMg.value?.toLong(), - ) - } catch (e: Exception) { - log.w { "Failed to insert PolarBeatSample: ${e.message}" } + val isCommitted = sessionLock.withLock { committedSessionIds.contains(currentSid) } + if (isCommitted) { + try { + database?.phoenixDatabaseQueries?.insertPolarBeatSample( + sessionId = currentSid, + timestampMs = beat.timestampMs, + phase = currentPhase, + hrBpm = beat.hr.toLong(), + rrMs = beat.rrIntervalMs, + rmssdMs = currentRmssd, + torsoPitchDeg = _torsoPitchDeg.value, + torsoRollDeg = _torsoRollDeg.value, + dynamicAccMg = _dynamicAccMg.value?.toLong(), + ) + } catch (e: Exception) { + log.e(e) { "Failed to insert PolarBeatSample for session $currentSid: ${e.message}" } + } + } else { + sessionLock.withLock { + bufferedBeatSamples.add( + BufferedBeatSample( + sessionId = currentSid, + 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 _rrClockTimestampMs.value = runningTs } @@ -1010,30 +1155,47 @@ class PolarH10BleManager( clearCachedTelemetry() return } - lastValidPacketTimestampMs = timeSource() + lastValidPacketTimestampMs = monotonicClock() _pmdMeasurement.tryEmit(measurement) _latestEcg.value = measurement _ecgStream.tryEmit(measurement) val currentSid = activeSessionId - if (currentSid != null && database != null) { - try { - val nowMs = timeSource() - val csv = measurement.samplesMicrovolts.joinToString(",") - database.phoenixDatabaseQueries.insertPolarEcgSegment( - sessionId = currentSid, - sensorTimestampNs = measurement.sensorTimestampNs, - wallClockTimestampMs = nowMs, - phase = currentPhase, - samplesUvCsv = csv, - ) - } catch (e: Exception) { - log.w { "Failed to insert PolarEcgSegment: ${e.message}" } + if (currentSid != null) { + val nowMs = wallClock() + val csv = measurement.samplesMicrovolts.joinToString(",") + val isCommitted = sessionLock.withLock { committedSessionIds.contains(currentSid) } + if (isCommitted) { + if (database != null) { + try { + database.phoenixDatabaseQueries.insertPolarEcgSegment( + sessionId = currentSid, + sensorTimestampNs = measurement.sensorTimestampNs, + wallClockTimestampMs = nowMs, + phase = currentPhase, + samplesUvCsv = csv, + ) + } catch (e: Exception) { + log.e(e) { "Failed to insert PolarEcgSegment for session $currentSid: ${e.message}" } + } + } + } else { + sessionLock.withLock { + bufferedEcgSegments.add( + BufferedEcgSegment( + sessionId = currentSid, + sensorTimestampNs = measurement.sensorTimestampNs, + wallClockTimestampMs = nowMs, + phase = currentPhase, + samplesUvCsv = csv, + ) + ) + } } } } is PolarPmdMeasurement.Acc -> { if (measurement.samples.isEmpty()) return - lastValidPacketTimestampMs = timeSource() + lastValidPacketTimestampMs = monotonicClock() _pmdMeasurement.tryEmit(measurement) _latestAcc.value = measurement _accStream.tryEmit(measurement) @@ -1230,11 +1392,32 @@ class PolarH10BleManager( } } + /** + * 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 { + try { + stopEcgStream() + } catch (e: Exception) { + e.rethrowIfCancellation() + log.w(e) { "Failed to stop ECG stream asynchronously: ${e.message}" } + } + try { + stopAccStream() + } catch (e: Exception) { + e.rethrowIfCancellation() + log.w(e) { "Failed to stop ACC stream asynchronously: ${e.message}" } + } + } + } + // ===== Watchdog for Packet Absence (> 3s) ===== private var watchdogJob: Job? = null private var lastValidPacketTimestampMs: Long = 0L - internal fun checkPacketAbsenceTimeout(currentTimestampMs: Long = timeSource()) { + internal fun checkPacketAbsenceTimeout(currentTimestampMs: Long = monotonicClock()) { 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." } @@ -1249,11 +1432,11 @@ class PolarH10BleManager( private fun startWatchdog() { watchdogJob?.cancel() - lastValidPacketTimestampMs = timeSource() + lastValidPacketTimestampMs = monotonicClock() watchdogJob = scope.launch { while (isActive && _isConnected.value) { delay(500L) - checkPacketAbsenceTimeout(timeSource()) + checkPacketAbsenceTimeout(monotonicClock()) } } } @@ -1312,6 +1495,7 @@ class PolarH10BleManager( internal fun clearCachedTelemetry() { _isContactDetected.value = false lastBeatTimestampMs = null + lastBeatMonotonicMs = null _hrMeasurement.value = null _rrClockTimestampMs.value = null _latestEcg.value = null 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 f2848bd6e..e0aa02fd6 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 @@ -1694,28 +1694,7 @@ class SqlDelightWorkoutRepository( override suspend fun getPolarBeatSamplesBySession(sessionId: String): List = withContext(Dispatchers.IO) { - val samples = queries.selectPolarBeatSamplesBySession(sessionId).executeAsList() - if (samples.size >= 2) { - val session = queries.selectSessionById(sessionId).executeAsOneOrNull() - if (session != null && session.rmssdMs != null && session.rmssdMs > 80.0) { - val filteredRmssd = PolarTelemetryUtils.calculateRmssdMs(samples.map { it.rrMs }) - if (filteredRmssd != null && abs(session.rmssdMs - filteredRmssd) > 1.0) { - queries.updateSessionPolarSummary( - id = session.id, - avgHeartRateBpm = session.avgHeartRateBpm, - maxHeartRateBpm = session.maxHeartRateBpm, - minHeartRateBpm = session.minHeartRateBpm, - avgRrIntervalMs = session.avgRrIntervalMs, - rmssdMs = filteredRmssd, - hrr60Bpm = session.hrr60Bpm, - hrr300Bpm = session.hrr300Bpm, - maxTorsoPitchDeg = session.maxTorsoPitchDeg, - maxTorsoRollDeg = session.maxTorsoRollDeg, - ) - } - } - } - samples + queries.selectPolarBeatSamplesBySession(sessionId).executeAsList() } override suspend fun getPolarEcgSegmentsBySession(sessionId: String): List = diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt index 4cc71061c..387891700 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt @@ -225,29 +225,6 @@ fun PolarH10SettingsCard( } } } - - 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( - "Automatically connects when entering active workout", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = isAutoReconnectEnabled, - onCheckedChange = { polarBleManager.setAutoReconnectEnabled(it) }, - ) - } } Spacer(modifier = Modifier.height(Spacing.small)) } @@ -427,19 +404,6 @@ fun PolarH10SettingsCard( onCheckedChange = { polarBleManager.setAutoReconnectEnabled(it) }, ) } - - if (rememberedAddress != null) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - OutlinedButton( - onClick = { polarBleManager.forgetRememberedDevice() }, - ) { - Text("Forget Sensor", color = MaterialTheme.colorScheme.error) - } - } - } } // Torso Biomechanics Posture Section diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt index 16502e1c5..8425e5e84 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt @@ -130,10 +130,7 @@ private fun PolarH10WorkoutStripContent( DisposableEffect(Unit) { onDispose { - scope.launch { - polarBleManager.stopEcgStream() - polarBleManager.stopAccStream() - } + polarBleManager.stopTelemetryStreamsAsync() } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt index 61216cf82..74dcd2650 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt @@ -44,6 +44,8 @@ import com.devil.phoenixproject.domain.model.ConnectionState import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.ui.theme.Spacing import org.koin.compose.koinInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlin.math.abs import kotlin.math.roundToInt @@ -84,6 +86,7 @@ fun PolarSessionAnalyticsCard( 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, @@ -98,7 +101,11 @@ fun PolarSessionAnalyticsCard( } ?: resolvedSessionId?.let { polarBleManager.getSessionSummary(it) } } - val rawSummary = latestSummary ?: fallbackSummary + val rawSummary = if (resolvedSessionId != null && latestSummary?.sessionId == resolvedSessionId) { + latestSummary + } else { + fallbackSummary + } // Historical samples queries for visualizations var beatSamples by remember(resolvedSessionId) { mutableStateOf>(emptyList()) } @@ -1155,14 +1162,14 @@ private fun TorsoBiomechanicsVsCableChart( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Hinge: ${(selectedSample.torsoPitchDeg?.let { (it * 10).roundToInt() / 10.0 } ?: 0.0)}°", + 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 } ?: 0.0)}°", + 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, @@ -1368,29 +1375,23 @@ private fun SavedEcgWaveformScrubber( ) { if (segments.isEmpty()) return - val allSamples = remember(segments) { - val list = mutableListOf() - for (seg in segments) { - val parts = seg.samplesUvCsv.split(',') - for (p in parts) { - p.trim().toIntOrNull()?.let { list.add(it) } - } - } - list + val segmentCounts = remember(segments) { + segments.map { seg -> if (seg.samplesUvCsv.isEmpty()) 0 else seg.samplesUvCsv.count { it == ',' } + 1 } } - if (allSamples.size < 10) return + 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 = (allSamples.size - windowSize).coerceAtLeast(0) + val maxStart = (totalSamples - windowSize).coerceAtLeast(0) - val currentPhase = remember(windowStart, segments) { + val currentPhase = remember(windowStart, segments, segmentCounts) { var accum = 0 var phase = segments.first().phase - for (seg in segments) { - val count = seg.samplesUvCsv.count { it == ',' } + 1 + for (i in segments.indices) { + val count = segmentCounts.getOrElse(i) { 0 } if (windowStart in accum until (accum + count)) { - phase = seg.phase + phase = segments[i].phase break } accum += count @@ -1452,8 +1453,8 @@ private fun SavedEcgWaveformScrubber( // Window details and slider val startSec = (windowStart / 130.0 * 10).roundToInt() / 10.0 - val endSec = (((windowStart + windowSize).coerceAtMost(allSamples.size)) / 130.0 * 10).roundToInt() / 10.0 - val totalSec = (allSamples.size / 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(), @@ -1468,7 +1469,7 @@ private fun SavedEcgWaveformScrubber( modifier = Modifier.weight(1f).padding(end = 8.dp), ) Text( - text = "${allSamples.size} samples @ 130 Hz", + text = "$totalSamples samples @ 130 Hz", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, @@ -1497,8 +1498,33 @@ private fun SavedEcgWaveformScrubber( .background(Color(0xFF081422)) .border(0.5.dp, Color(0xFF1B3A5A), RoundedCornerShape(8.dp)), ) { - val windowSlice = remember(windowStart, allSamples) { - allSamples.subList(windowStart, (windowStart + windowSize).coerceAtMost(allSamples.size)) + 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()) { 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 31fc4f4fa..5c460dc3a 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 @@ -9571,6 +9571,7 @@ class ActiveSessionEngine( repMetrics = snapshot.repMetrics, repBiomechanics = snapshot.biomechanicsRepResults, ) + polarBleManager?.flushSessionTelemetry(sessionId) // Issue #869: the set is durable; Set Summary may now appear. commitSettled?.complete(Unit) snapshot.singleExerciseDefaults?.let { defaults -> @@ -10859,6 +10860,7 @@ class ActiveSessionEngine( repMetrics = emptyList(), repBiomechanics = emptyList(), ) + polarBleManager?.flushSessionTelemetry(session.id, fallbackSessionId = coordinator.currentSessionId) 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 638f7713c..3bd670f36 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 @@ -539,6 +539,11 @@ class DefaultWorkoutSessionManager( is WorkoutState.SetSummary -> { polarBleManager.updateSessionPhase("REST") } + is WorkoutState.Idle -> { + if (!polarBleManager.isCooldownActive.value) { + polarBleManager.discardActiveRecording() + } + } else -> {} } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtils.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtils.kt index d3e190977..e489e1213 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtils.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtils.kt @@ -1,23 +1,12 @@ package com.devil.phoenixproject.presentation.util import com.devil.phoenixproject.domain.model.PolarRrBeat -import kotlin.math.abs -import kotlin.math.roundToInt -import kotlin.math.sqrt +import com.devil.phoenixproject.util.PolarTelemetryUtils /** * Computes rolling RMSSD (Root Mean Square of Successive Differences) in milliseconds - * over a sliding time window (default 60 seconds, min 30 seconds recommended). - * - * Successive differences are computed on adjacent [PolarRrBeat] entries that fall within - * [currentTimeMs - windowMs, currentTimeMs]. Beats separated by excessive time gaps (> 3500ms), - * with non-physiological intervals (< 250ms or > 2500ms), or with relative RR-delta > 25% are - * excluded from difference pairs to prevent dropped-beat artifact inflation. - * - * @param beats Ordered list of [PolarRrBeat] events. - * @param windowMs Time window in milliseconds (default 60,000ms = 60s). - * @param currentTimeMs Reference timestamp for the window edge. - * @return RMSSD in milliseconds, or null if fewer than 2 consecutive valid beats are available. + * over a sliding time window (default 60 seconds). + * Filters beats by the sliding window and delegates to canonical [PolarTelemetryUtils.calculateRmssdFromBeats]. */ fun computeRollingRmssd( beats: List, @@ -25,49 +14,18 @@ fun computeRollingRmssd( currentTimeMs: Long, ): Double? { if (beats.size < 2) return null - val windowStart = currentTimeMs - windowMs val windowBeats = beats.filter { it.timestampMs in windowStart..currentTimeMs } - if (windowBeats.size < 2) return null - - var sumSquaredDiffs = 0.0 - var count = 0 - - for (i in 0 until windowBeats.size - 1) { - val b1 = windowBeats[i] - val b2 = windowBeats[i + 1] - - // Validate individual RR intervals (250ms = 240 BPM, 2500ms = 24 BPM) - if (b1.rrIntervalMs !in 250.0..2500.0 || b2.rrIntervalMs !in 250.0..2500.0) { - continue - } - - // Validate inter-beat time gap (beats should be contiguous within reasonable tolerance) - val timeGap = b2.timestampMs - b1.timestampMs - if (timeGap !in 200..3500) { - continue - } - - val diff = abs(b2.rrIntervalMs - b1.rrIntervalMs) - // 25% relative RR-delta artifact filter - if (diff > maxOf(b1.rrIntervalMs, b2.rrIntervalMs) * 0.25) { - continue - } - - sumSquaredDiffs += diff * diff - count++ - } - - if (count == 0) return null - return sqrt(sumSquaredDiffs / count) + return PolarTelemetryUtils.calculateRmssdFromBeats(windowBeats) } /** * Format milliseconds to a clean integer string representation. */ -fun formatRrMs(rrMs: Double?): String = rrMs?.let { "${it.roundToInt()} ms" } ?: "—" +fun formatRrMs(rrMs: Double?): String = PolarTelemetryUtils.formatRrMs(rrMs) /** * Format RMSSD to a clean integer string representation. */ -fun formatRmssdMs(rmssd: Double?): String = rmssd?.let { "${it.roundToInt()} ms" } ?: "—" +fun formatRmssdMs(rmssd: Double?): String = PolarTelemetryUtils.formatRmssdMs(rmssd) + 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 d3ddb8377..e0035dad0 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt @@ -194,7 +194,7 @@ object BleConstants { const val DESIRED_PMD_MTU = 232 - object Commands { + 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) @@ -203,12 +203,12 @@ object BleConstants { val STOP_ACC = byteArrayOf(0x03, 0x02) } - val PMD_START_ECG_CMD: ByteArray get() = Commands.START_ECG - val PMD_START_ACC_CMD: ByteArray get() = Commands.START_ACC - val PMD_START_ACC_ASCENDING_CMD: ByteArray get() = Commands.START_ACC_ASCENDING - val PMD_START_ACC_FALLBACK_25HZ_CMD: ByteArray get() = Commands.START_ACC_FALLBACK_25HZ - val PMD_STOP_ECG_CMD: ByteArray get() = Commands.STOP_ECG - val PMD_STOP_ACC_CMD: ByteArray get() = Commands.STOP_ACC + 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 364265ffd..17097f7a3 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt @@ -2637,9 +2637,14 @@ abstract class BaseDataBackupManager( torsoRollDeg = beat.torsoRollDeg?.toDouble(), dynamicAccMg = beat.dynamicAccMg?.toLong(), ) - queries.markWorkoutComponentDirty(beat.sessionId) - holdGroupOfRestoredChild(beat.sessionId) - polarBeatSamplesImported++ + val inserted = queries.selectChangedRowCount().executeAsOne() > 0L + if (inserted) { + queries.markWorkoutComponentDirty(beat.sessionId) + holdGroupOfRestoredChild(beat.sessionId) + polarBeatSamplesImported++ + } else { + polarBeatSamplesSkipped++ + } } } nav.endArray() @@ -2670,9 +2675,14 @@ abstract class BaseDataBackupManager( phase = segment.phase, samplesUvCsv = segment.samplesUvCsv, ) - queries.markWorkoutComponentDirty(segment.sessionId) - holdGroupOfRestoredChild(segment.sessionId) - polarEcgSegmentsImported++ + val inserted = queries.selectChangedRowCount().executeAsOne() > 0L + if (inserted) { + queries.markWorkoutComponentDirty(segment.sessionId) + holdGroupOfRestoredChild(segment.sessionId) + polarEcgSegmentsImported++ + } else { + polarEcgSegmentsSkipped++ + } } } nav.endArray() diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt index 703e469a6..56f832750 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt @@ -1,5 +1,6 @@ package com.devil.phoenixproject.util +import com.devil.phoenixproject.domain.model.PolarRrBeat import kotlin.math.PI import kotlin.math.abs import kotlin.math.atan2 @@ -76,42 +77,28 @@ object PolarTelemetryUtils { return abs(totalMagnitude - GRAVITY_1G_MG).roundToInt() } - /** - * Compute all torso biomechanics metrics for a single 3D ACC sample. - */ - fun computeTorsoBiomechanics( - xMg: Double, - yMg: Double, - zMg: Double, - tarePitchDeg: Double = 0.0, - tareRollDeg: Double = 0.0, - ): PolarTorsoBiomechanics { - val pitch = calculateTorsoPitchDeg(xMg, yMg, zMg, tarePitchDeg) - val roll = calculateTorsoRollDeg(xMg, yMg, zMg, tareRollDeg) - val dynamicAcc = calculateDynamicAccMg(xMg, yMg, zMg) - return PolarTorsoBiomechanics( - pitchDeg = pitch, - rollDeg = roll, - dynamicAccMg = dynamicAcc, - filteredXMg = xMg, - filteredYMg = yMg, - filteredZMg = zMg, - ) - } - /** * Compute Root Mean Square of Successive Differences (RMSSD) in milliseconds from R-R intervals. - * Applies physiological bounds (250.0..2500.0 ms) and a 25% relative RR-delta artifact filter - * (abs(rr2 - rr1) <= maxOf(rr1, rr2) * 0.25) to reject missed-beat spikes and noise. + * Applies physiological bounds (250.0..2500.0 ms), an optional inter-beat time gap check (200..3500 ms), + * and a 25% relative RR-delta artifact filter (abs(rr2 - rr1) <= maxOf(rr1, rr2) * 0.25) + * to reject missed-beat spikes and noise. */ - fun calculateRmssdMs(rrs: List): Double? { + fun calculateRmssdMs( + rrs: List, + 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 @@ -120,6 +107,24 @@ object PolarTelemetryUtils { 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 }, + ) + } + + /** + * Format milliseconds to a clean integer string representation. + */ + fun formatMs(ms: Double?): String = ms?.let { "${it.roundToInt()} ms" } ?: "—" + fun formatRrMs(rrMs: Double?): String = formatMs(rrMs) + fun formatRmssdMs(rmssd: Double?): String = formatMs(rmssd) } /** diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtilsTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarRollingTelemetryUtilsTest.kt similarity index 98% rename from shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtilsTest.kt rename to shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarRollingTelemetryUtilsTest.kt index f7c3f099e..aa1906f9f 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtilsTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarRollingTelemetryUtilsTest.kt @@ -6,7 +6,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue -class PolarTelemetryUtilsTest { +class PolarRollingTelemetryUtilsTest { @Test fun testEmptyOrSingleBeatReturnsNull() { diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtilsTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtilsTest.kt index 0ffcbfd30..164affcff 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtilsTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtilsTest.kt @@ -12,11 +12,11 @@ class PolarTelemetryUtilsTest { @Test fun standingUprightProducesNearZeroPitchAndRollAndZeroDynamicAcc() { // Upright: 1G pointing straight down along -X - val x = -1000.0 - val y = 0.0 - val z = 0.0 + val x = -1000 + val y = 0 + val z = 0 - val metrics = PolarTelemetryUtils.computeTorsoBiomechanics(x, y, z) + 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}") @@ -26,11 +26,11 @@ class PolarTelemetryUtilsTest { @Test fun fortyFiveDegreeForwardHingeProducesFortyFiveDegreesPitch() { // 45° forward hinge: gravity pulls along -X (707 mG) and -Z (707 mG) - val x = -707.106 - val y = 0.0 - val z = -707.106 + val x = -707 + val y = 0 + val z = -707 - val metrics = PolarTelemetryUtils.computeTorsoBiomechanics(x, y, z) + 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}") @@ -40,11 +40,11 @@ class PolarTelemetryUtilsTest { @Test fun backwardLeanProducesNegativePitch() { // Leaning backward: gravity pulls along +Z - val x = -707.106 - val y = 0.0 - val z = 707.106 + val x = -707 + val y = 0 + val z = 707 - val metrics = PolarTelemetryUtils.computeTorsoBiomechanics(x, y, z) + val metrics = PolarAccFilter().processSample(x, y, z) assertTrue(abs(metrics.pitchDeg - (-45.0)) < toleranceDeg, "Backward lean pitch should be ~ -45°, got ${metrics.pitchDeg}") } @@ -52,11 +52,11 @@ class PolarTelemetryUtilsTest { @Test fun horizontalHingeProducesNinetyDegreesPitch() { // 90° horizontal bend (torso parallel to ground): gravity entirely along -Z - val x = 0.0 - val y = 0.0 - val z = -1000.0 + val x = 0 + val y = 0 + val z = -1000 - val metrics = PolarTelemetryUtils.computeTorsoBiomechanics(x, y, z) + 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}") @@ -65,11 +65,11 @@ class PolarTelemetryUtilsTest { @Test fun lateralTiltProducesAccurateRoll() { // 30° lateral tilt to right: Y = 500 mG, X = -866 mG, Z = 0 - val x = -866.025 - val y = 500.0 - val z = 0.0 + val x = -866 + val y = 500 + val z = 0 - val metrics = PolarTelemetryUtils.computeTorsoBiomechanics(x, y, z) + 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}") 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 c42fe13a5..917f89cea 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 @@ -128,6 +128,8 @@ fun ActiveWorkoutScreen( 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 @@ -296,7 +298,7 @@ fun ActiveWorkoutScreen( // 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 @@ -305,16 +307,14 @@ fun ActiveWorkoutScreen( } when { workoutState is WorkoutState.Completed -> { - val hasPolarActivity = polarBleManager.isCooldownActive.value || - polarBleManager.connectionState.value is ConnectionState.Connected || - polarBleManager.latestSessionSummary.value != null - if (!hasPolarActivity) { - Logger.d { "ActiveWorkoutScreen: Workout completed (no Polar active), navigating back in 2s" } + 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 telemetry/cooldown active; remaining on summary" } + Logger.d { "ActiveWorkoutScreen: Workout completed with Polar cooldown active; remaining on summary" } } } 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 4ae468d02..61dde0236 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 @@ -1187,7 +1187,7 @@ private fun GroupedRoutineCard( // Polar H10 Cardiac & Biomechanics Telemetry Section (rendered below all exercises & rep details) val polarSession = groupedItem.sessions.find { it.avgHeartRateBpm != null || it.maxHeartRateBpm != null || it.hrr60Bpm != null - } ?: groupedItem.sessions.lastOrNull() ?: groupedItem.sessions.firstOrNull() + } if (polarSession != null) { Spacer(modifier = Modifier.height(Spacing.medium)) From 135b177bd06bc50dc47c62663a16da38af10bd3b Mon Sep 17 00:00:00 2001 From: StuGotz Date: Fri, 23 Oct 2026 20:45:49 -0500 Subject: [PATCH 15/25] fix(polar): resolve reviewer requested architectural, DI, schema parity, and lifecycle changes - Schema Migration: Revert Polar DDL from migration 58 to keep custom equipment intact; create migration 59 with PolarBeatSample and PolarEcgSegment tables, updating MigrationStatements, SchemaManifest, and retargeting SchemaParityTest to expected schema version 60. - Koin DI & UI Ratchet: Decouple PolarH10BleManager injection from Compose components, threading manager down from MainViewModel through screen hierarchies; adopt MaterialTheme shapes and color tokens across Polar cards. - State Leaks & Cooldown Isolation: Guard cooldown actions and session summaries with recordedSessionId; ensure clean cancellation/restoration when routines are resumed or explicitly restarted; pass showCooldown=false for historical workouts. - Database Autocommit & Backup OOM: Buffer live telemetry samples in memory and flush in single transaction blocks upon set/cooldown completion; stream backup telemetry rows per-session rather than querying global tables. - Cleanup: Remove dead code, testing toggles, duplicate disconnect listeners, and redundant utility copies; strip UTF-8 BOM from DataModule. --- docs/qa/persisted-file-upgrade.md | 2 +- docs/qa/profile-schema42-fixture.md | 6 +- .../qa/Invoke-PhoenixPersistedFileUpgrade.ps1 | 2 +- .../data/ble/PolarH10PersistenceTest.kt | 5 +- .../data/local/SchemaParityTest.kt | 10 +- .../data/ble/PolarH10BleManager.kt | 156 ++++++++---------- .../data/local/MigrationStatements.kt | 8 +- .../data/local/SchemaManifest.kt | 10 +- .../com/devil/phoenixproject/di/DataModule.kt | 2 +- .../components/PolarH10SettingsCard.kt | 90 +--------- .../components/PolarH10WorkoutStrip.kt | 21 ++- .../components/PolarSessionAnalyticsCard.kt | 93 ++++++----- .../presentation/util/PolarTelemetryUtils.kt | 31 ---- .../presentation/viewmodel/MainViewModel.kt | 17 +- .../phoenixproject/util/DataBackupManager.kt | 58 ++++--- .../util/PolarTelemetryUtils.kt | 28 +++- .../phoenixproject/database/migrations/58.sqm | 53 ------ .../phoenixproject/database/migrations/59.sqm | 52 ++++++ .../data/ble/PolarH10BleManagerTest.kt | 59 +++++-- .../presentation/PolarH10WiringTest.kt | 37 ----- .../util/PolarRollingTelemetryUtilsTest.kt | 4 +- .../screen/ActiveWorkoutScreen.kt | 3 +- .../presentation/screen/AnalyticsScreen.kt | 1 + .../presentation/screen/HistoryTab.kt | 12 ++ .../screen/RoutineCompleteScreen.kt | 9 +- .../presentation/screen/SetSummaryCard.kt | 7 +- .../presentation/screen/WorkoutTab.kt | 10 +- 27 files changed, 360 insertions(+), 426 deletions(-) delete mode 100644 shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtils.kt create mode 100644 shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/59.sqm delete mode 100644 shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/PolarH10WiringTest.kt 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/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 index 39011ff62..ab5dd22e8 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10PersistenceTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10PersistenceTest.kt @@ -155,7 +155,7 @@ class PolarH10PersistenceTest { assertEquals(123456789L, ecgSeg.sensorTimestampNs) assertEquals("150,250,350,450", ecgSeg.samplesUvCsv) - // 7. Post-workout cooldown: subsequent beats insert directly to DB without buffering + // 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, @@ -164,7 +164,10 @@ class PolarH10PersistenceTest { ) 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) 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 ce09dae7c..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 @@ -1303,18 +1303,18 @@ class SchemaParityTest { } @Test - fun `migration 58 adds Polar tables and columns to MetricSample and WorkoutSession`() { + 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, 58) + buildSchemaAtVersion(driver, 59) assertEquals(false, getTables(driver).contains("PolarBeatSample"), scenario) assertEquals(false, getTables(driver).contains("PolarEcgSegment"), scenario) if (fallback) { - val results = applyMigrationResilient(driver, 58) + val results = applyMigrationResilient(driver, 59) assertEquals(true, results.all { it.success }, scenario) } else { - PhoenixDatabase.Schema.migrate(driver, 58, 59) + PhoenixDatabase.Schema.migrate(driver, 59, 60) } assertEquals(true, getTables(driver).contains("PolarBeatSample"), scenario) @@ -1331,7 +1331,7 @@ class SchemaParityTest { // ==================== 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/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt index fb1f2977c..4dc29ca2c 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -166,6 +166,7 @@ class PolarH10BleManager( 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() @@ -204,6 +205,7 @@ class PolarH10BleManager( val latestSessionSummary: StateFlow = _latestSessionSummary.asStateFlow() private val completedCooldownSessionIds = mutableSetOf() + private var completedCooldownSessionId: String? = null val currentRecordingSessionId: String? get() = activeSessionId @@ -218,7 +220,9 @@ class PolarH10BleManager( _cooldownStartHeartRate.value = null _activeCooldownSessionId.value = null completedCooldownSessionIds.remove(sessionId) + completedCooldownSessionId = null activeSessionId = sessionId + recordedSessionId = sessionId currentPhase = phase sessionLock.withLock { bufferedBeatSamples.clear() @@ -312,6 +316,8 @@ class PolarH10BleManager( } lastBeatMonotonicMs = null activeSessionId = null + recordedSessionId = null + completedCooldownSessionId = null currentPhase = "WARMUP" _latestSessionSummary.value = null } @@ -325,7 +331,19 @@ class PolarH10BleManager( fun updateSessionPhase(phase: String) { if (phase == "WORKING_SET" || phase == "WARMUP") { if (_isCooldownActive.value || currentPhase == "POST_WORKOUT_COOLDOWN") { - cancelCooldown() + cooldownJob?.cancel() + cooldownJob = null + cooldownStartTimestampMs = null + _isCooldownActive.value = false + _cooldownSecondsRemaining.value = 0 + val targetSessionId = activeSessionId ?: _activeCooldownSessionId.value + if (targetSessionId != null) { + completedCooldownSessionIds.remove(targetSessionId) + } + _activeCooldownSessionId.value = null + if (activeSessionId == null && recordedSessionId != null) { + activeSessionId = recordedSessionId + } } } currentPhase = phase @@ -341,10 +359,24 @@ class PolarH10BleManager( if (targetSessionId != null) { completedCooldownSessionIds.remove(targetSessionId) } + activeSessionId = null + _activeCooldownSessionId.value = null + recordedSessionId = null + completedCooldownSessionId = null + scope.launch { + stopEcgStream() + stopAccStream() + } } fun triggerCooldown(sessionId: String? = null, forceRestart: Boolean = false) { - endSessionRecording(sessionId, forceRestart = forceRestart) + val targetId: String = sessionId ?: recordedSessionId ?: (if (forceRestart) completedCooldownSessionId else null) ?: return + if (targetId != recordedSessionId && (!forceRestart || targetId != completedCooldownSessionId)) return + if (forceRestart && targetId == completedCooldownSessionId) { + recordedSessionId = targetId + activeSessionId = targetId + } + endSessionRecording(targetId, forceRestart = forceRestart) if (_connectionState.value is ConnectionState.Connected) { if (!isAccStreaming) scope.launch { startAccStream() } if (!isEcgStreaming) scope.launch { startEcgStream() } @@ -375,11 +407,25 @@ class PolarH10BleManager( persistPolarSummary(targetSessionId) _latestSessionSummary.value = getSessionSummary(targetSessionId) completedCooldownSessionIds.add(targetSessionId) + completedCooldownSessionId = targetSessionId + flushSessionTelemetry(targetSessionId) + } + activeSessionId = null + _activeCooldownSessionId.value = null + recordedSessionId = null + scope.launch { + stopEcgStream() + stopAccStream() } } fun endSessionRecording(sessionId: String? = null, forceRestart: Boolean = false) { - val targetSessionId = sessionId ?: activeSessionId ?: _activeCooldownSessionId.value ?: return + val targetSessionId: String = sessionId ?: recordedSessionId ?: (if (forceRestart) completedCooldownSessionId else null) ?: return + if (targetSessionId != recordedSessionId && (!forceRestart || targetSessionId != completedCooldownSessionId)) return + if (forceRestart && targetSessionId == completedCooldownSessionId) { + recordedSessionId = targetSessionId + activeSessionId = targetSessionId + } if (!forceRestart && completedCooldownSessionIds.contains(targetSessionId)) { Logger.d { "endSessionRecording: Cooldown already completed for session $targetSessionId; skipping" } return @@ -465,12 +511,16 @@ class PolarH10BleManager( persistPolarSummary(targetSessionId) } completedCooldownSessionIds.add(targetSessionId) + completedCooldownSessionId = targetSessionId _isCooldownActive.value = false _cooldownSecondsRemaining.value = 0 _latestSessionSummary.value = getSessionSummary(targetSessionId) - if (activeSessionId == targetSessionId) { - activeSessionId = null - } + flushSessionTelemetry(targetSessionId) + activeSessionId = null + _activeCooldownSessionId.value = null + recordedSessionId = null + stopEcgStream() + stopAccStream() } } @@ -482,7 +532,7 @@ class PolarH10BleManager( ) fun getSessionSummary(sessionId: String): PolarSessionSummary? { - if (activeSessionId != null && activeSessionId != sessionId) return null + if (sessionId != recordedSessionId) return null val snapshot = sessionLock.withLock { SessionSampleSnapshot( hrs = sessionHrs.toList(), @@ -579,20 +629,6 @@ class PolarH10BleManager( private val _hrMeasurement = MutableStateFlow(null) val hrMeasurement: StateFlow = _hrMeasurement.asStateFlow() - private val _hrMeasurements = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 16, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - val hrMeasurements: SharedFlow = _hrMeasurements.asSharedFlow() - - private val _pmdMeasurement = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 128, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - val pmdMeasurement: SharedFlow = _pmdMeasurement.asSharedFlow() - private val _ecgStream = MutableSharedFlow( replay = 0, extraBufferCapacity = 128, @@ -620,12 +656,8 @@ class PolarH10BleManager( ) val rrBeats: SharedFlow = _rrBeats.asSharedFlow() - private val _rrClockTimestampMs = MutableStateFlow(null) - val rrClockTimestampMs: StateFlow = _rrClockTimestampMs.asStateFlow() - // ===== Internal Connection References ===== private var peripheral: Peripheral? = null - val currentPeripheral: Peripheral? get() = peripheral private var scanJob: Job? = null private val discoveredAdvertisements = mutableMapOf() @@ -647,9 +679,6 @@ class PolarH10BleManager( private var isEcgStreaming = false private var isAccStreaming = false - val isEcgActive: Boolean get() = isEcgStreaming - val isAccActive: Boolean get() = isAccStreaming - // ===== Chained RR Clock State ===== private var lastBeatTimestampMs: Long? = null @@ -945,7 +974,6 @@ class PolarH10BleManager( _isContactDetected.value = true lastValidPacketTimestampMs = monotonicClock() _hrMeasurement.value = measurement - _hrMeasurements.emit(measurement) if (measurement.rrIntervalsMs.isNotEmpty()) { processRrIntervals(measurement) @@ -1023,10 +1051,9 @@ class PolarH10BleManager( if (recentRrs.size > 30) recentRrs.removeFirst() PolarTelemetryUtils.calculateRmssdMs(recentRrs.toList()) } - val isCommitted = sessionLock.withLock { committedSessionIds.contains(currentSid) } - if (isCommitted) { - try { - database?.phoenixDatabaseQueries?.insertPolarBeatSample( + sessionLock.withLock { + bufferedBeatSamples.add( + BufferedBeatSample( sessionId = currentSid, timestampMs = beat.timestampMs, phase = currentPhase, @@ -1037,32 +1064,13 @@ class PolarH10BleManager( torsoRollDeg = _torsoRollDeg.value, dynamicAccMg = _dynamicAccMg.value?.toLong(), ) - } catch (e: Exception) { - log.e(e) { "Failed to insert PolarBeatSample for session $currentSid: ${e.message}" } - } - } else { - sessionLock.withLock { - bufferedBeatSamples.add( - BufferedBeatSample( - sessionId = currentSid, - 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 - _rrClockTimestampMs.value = runningTs } private suspend fun ensurePmdSubscribed(p: Peripheral): Boolean = pmdMutex.withLock { @@ -1156,47 +1164,28 @@ class PolarH10BleManager( return } lastValidPacketTimestampMs = monotonicClock() - _pmdMeasurement.tryEmit(measurement) _latestEcg.value = measurement _ecgStream.tryEmit(measurement) val currentSid = activeSessionId if (currentSid != null) { val nowMs = wallClock() val csv = measurement.samplesMicrovolts.joinToString(",") - val isCommitted = sessionLock.withLock { committedSessionIds.contains(currentSid) } - if (isCommitted) { - if (database != null) { - try { - database.phoenixDatabaseQueries.insertPolarEcgSegment( - sessionId = currentSid, - sensorTimestampNs = measurement.sensorTimestampNs, - wallClockTimestampMs = nowMs, - phase = currentPhase, - samplesUvCsv = csv, - ) - } catch (e: Exception) { - log.e(e) { "Failed to insert PolarEcgSegment for session $currentSid: ${e.message}" } - } - } - } else { - sessionLock.withLock { - bufferedEcgSegments.add( - BufferedEcgSegment( - sessionId = currentSid, - sensorTimestampNs = measurement.sensorTimestampNs, - wallClockTimestampMs = nowMs, - phase = currentPhase, - samplesUvCsv = csv, - ) + sessionLock.withLock { + bufferedEcgSegments.add( + BufferedEcgSegment( + sessionId = currentSid, + sensorTimestampNs = measurement.sensorTimestampNs, + wallClockTimestampMs = nowMs, + phase = currentPhase, + samplesUvCsv = csv, ) - } + ) } } } is PolarPmdMeasurement.Acc -> { if (measurement.samples.isEmpty()) return lastValidPacketTimestampMs = monotonicClock() - _pmdMeasurement.tryEmit(measurement) _latestAcc.value = measurement _accStream.tryEmit(measurement) for (s in measurement.samples) { @@ -1497,7 +1486,6 @@ class PolarH10BleManager( lastBeatTimestampMs = null lastBeatMonotonicMs = null _hrMeasurement.value = null - _rrClockTimestampMs.value = null _latestEcg.value = null _latestAcc.value = null @@ -1537,10 +1525,6 @@ class PolarH10BleManager( cleanupExistingConnection() } - suspend fun cleanup() { - disconnect() - } - internal fun isPolarH10Advertisement(adv: Advertisement): Boolean { val name = adv.name if (name != null && name.startsWith("Polar H10", ignoreCase = true)) { 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 0fe4abaf9..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,14 +1300,18 @@ 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) and - // Polar H10 multi-tier telemetry persistence. Mirrors 58.sqm exactly. + // 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", "ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_local_generation INTEGER NOT NULL DEFAULT 0", "ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_server_revision INTEGER NOT NULL DEFAULT 0", "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", 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 9dba45c0b..35b01ae54 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 @@ -1131,7 +1131,7 @@ internal val manifestTables: List = listOf( """.trimIndent(), ), - // MetricSample -- initial schema, full shape with Polar telemetry (migration 58) + // MetricSample -- initial schema, full shape with Polar telemetry (migration 59) SchemaTableOperation( table = "MetricSample", createSql = """ @@ -1160,7 +1160,7 @@ internal val manifestTables: List = listOf( """.trimIndent(), ), - // PolarBeatSample -- migration 58 + // PolarBeatSample -- migration 59 SchemaTableOperation( table = "PolarBeatSample", createSql = """ @@ -1180,7 +1180,7 @@ internal val manifestTables: List = listOf( """.trimIndent(), ), - // PolarEcgSegment -- migration 58 + // PolarEcgSegment -- migration 59 SchemaTableOperation( table = "PolarEcgSegment", createSql = """ @@ -2008,10 +2008,10 @@ 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 58) ────────────────────────────────── + // ── 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 58) ────────────────────────────────── + // ── 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 ────────────────────────────────────────────────── 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 99974fd4e..d8be81fce 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt @@ -1,4 +1,4 @@ -package com.devil.phoenixproject.di +package com.devil.phoenixproject.di import app.cash.sqldelight.db.SqlDriver import com.devil.phoenixproject.data.csv.CsvImportInbox diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt index 387891700..9deed85d8 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt @@ -47,8 +47,6 @@ 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.ui.theme.Spacing -import com.devil.phoenixproject.presentation.util.formatRmssdMs -import com.devil.phoenixproject.presentation.util.formatRrMs import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -76,19 +74,7 @@ fun PolarH10SettingsCard( val torsoRollDeg by polarBleManager.torsoRollDeg.collectAsState() val dynamicAccMg by polarBleManager.dynamicAccMg.collectAsState() - var isEcgEnabled by remember { mutableStateOf(false) } - var isAccEnabled by remember { mutableStateOf(false) } - // Sync switch states when connection is lost - LaunchedEffect(connectionState) { - if (connectionState !is ConnectionState.Connected) { - isEcgEnabled = false - isAccEnabled = false - } - } - - val latestEcgSampleUv = if (isEcgEnabled) latestEcg?.samplesMicrovolts?.lastOrNull() else null - val latestAccSample = if (isAccEnabled) latestAcc?.samples?.lastOrNull() else null Card( modifier = modifier @@ -126,7 +112,7 @@ fun PolarH10SettingsCard( Icon( Icons.Default.Favorite, contentDescription = "Polar H10", - tint = Color.White, + tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(24.dp), ) } @@ -472,80 +458,10 @@ fun PolarH10SettingsCard( ) { Text("Reset Tare") } - } - } - - Spacer(modifier = Modifier.height(Spacing.small)) - - // Stream Testing Toggles - Text( - "Live Telemetry Streams (Testing)", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(modifier = Modifier.height(4.dp)) - - // 130 Hz ECG toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text("130 Hz ECG Stream", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - Text( - if (isEcgEnabled && latestEcgSampleUv != null) { - "Active: ${latestEcgSampleUv} µV" - } else { - "14-bit microvolt voltage stream" - }, - style = MaterialTheme.typography.bodySmall, - color = if (isEcgEnabled) Color(0xFF00C853) else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = isEcgEnabled, - onCheckedChange = { enable -> - isEcgEnabled = enable - scope.launch { - if (enable) polarBleManager.startEcgStream() else polarBleManager.stopEcgStream() - } - }, - ) - } - - // 50 Hz ACC toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text("50 Hz Chest ACC Stream", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - Text( - if (isAccEnabled && latestAccSample != null) { - "X: ${latestAccSample?.xMg} Y: ${latestAccSample?.yMg} Z: ${latestAccSample?.zMg} mG" - } else { - "16-bit ±8G tri-axial chest acceleration" - }, - style = MaterialTheme.typography.bodySmall, - color = if (isAccEnabled) Color(0xFF00C853) else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = isAccEnabled, - onCheckedChange = { enable -> - isAccEnabled = enable - scope.launch { - if (enable) polarBleManager.startAccStream() else polarBleManager.stopAccStream() - } - }, - ) - } + } } } + } } } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt index 8425e5e84..2f535839e 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt @@ -56,12 +56,10 @@ 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.presentation.util.computeRollingRmssd -import com.devil.phoenixproject.presentation.util.formatRmssdMs -import com.devil.phoenixproject.presentation.util.formatRrMs +import com.devil.phoenixproject.util.PolarTelemetryUtils +import com.devil.phoenixproject.util.computeRollingRmssd import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import org.koin.compose.koinInject import kotlin.math.roundToInt import kotlin.math.sqrt @@ -70,8 +68,9 @@ private const val MAX_ECG_DISPLAY_SAMPLES = 260 // ~2 seconds at 130 Hz @Composable fun PolarH10WorkoutStrip( modifier: Modifier = Modifier, - polarBleManager: PolarH10BleManager = koinInject(), + polarBleManager: PolarH10BleManager? = null, ) { + if (polarBleManager == null) return val connectionState by polarBleManager.connectionState.collectAsState() val isConnected = connectionState is ConnectionState.Connected @@ -162,7 +161,7 @@ private fun PolarH10WorkoutStripContent( .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 4.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, tonalElevation = 4.dp, border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), ) { @@ -215,7 +214,7 @@ private fun PolarH10WorkoutStripContent( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = formatRrMs(latestRrMs), + text = PolarTelemetryUtils.formatMs(latestRrMs), style = MaterialTheme.typography.labelSmall, fontSize = 10.sp, fontWeight = FontWeight.Bold, @@ -231,7 +230,7 @@ private fun PolarH10WorkoutStripContent( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = formatRmssdMs(rollingRmssd), + text = PolarTelemetryUtils.formatMs(rollingRmssd), style = MaterialTheme.typography.labelSmall, fontSize = 10.sp, fontWeight = FontWeight.Bold, @@ -247,9 +246,9 @@ private fun PolarH10WorkoutStripContent( .weight(1f) .height(38.dp) .padding(horizontal = 6.dp) - .clip(RoundedCornerShape(6.dp)) + .clip(MaterialTheme.shapes.small) .background(Color(0xFF0D1B2A)) - .border(0.5.dp, Color(0xFF1E3A5F), RoundedCornerShape(6.dp)), + .border(0.5.dp, Color(0xFF1E3A5F), MaterialTheme.shapes.small), contentAlignment = Alignment.Center, ) { EcgWaveformCanvas( @@ -316,7 +315,7 @@ private fun PolarH10WorkoutStripContent( Surface( onClick = { polarBleManager.tareCurrentPosture() }, - shape = RoundedCornerShape(6.dp), + shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.secondaryContainer, border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)), ) { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt index 74dcd2650..fa3e521c6 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt @@ -43,7 +43,6 @@ import com.devil.phoenixproject.database.PolarEcgSegment import com.devil.phoenixproject.domain.model.ConnectionState import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.ui.theme.Spacing -import org.koin.compose.koinInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlin.math.abs @@ -66,20 +65,20 @@ fun PolarSessionAnalyticsCard( session: WorkoutSession? = null, showCooldown: Boolean = true, modifier: Modifier = Modifier, - polarBleManager: PolarH10BleManager = koinInject(), - workoutRepository: WorkoutRepository = koinInject(), + polarBleManager: PolarH10BleManager? = null, + workoutRepository: WorkoutRepository? = null, ) { - val connectionState by polarBleManager.connectionState.collectAsState() - val isCooldownActive by polarBleManager.isCooldownActive.collectAsState() - val cooldownSecondsRemaining by polarBleManager.cooldownSecondsRemaining.collectAsState() - val cooldownHrr60 by polarBleManager.cooldownHrr60.collectAsState() - val cooldownHrr300 by polarBleManager.cooldownHrr300.collectAsState() - val cooldownStartHr by polarBleManager.cooldownStartHeartRate.collectAsState() - val latestSummary by polarBleManager.latestSessionSummary.collectAsState() - val hrMeasurement by polarBleManager.hrMeasurement.collectAsState() - val activeCooldownSessionId by polarBleManager.activeCooldownSessionId.collectAsState() - - val resolvedSessionId = sessionId ?: session?.id ?: activeCooldownSessionId ?: polarBleManager.currentRecordingSessionId + val connectionState = polarBleManager?.connectionState?.collectAsState()?.value ?: ConnectionState.Disconnected + val isCooldownActive = polarBleManager?.isCooldownActive?.collectAsState()?.value ?: false + val cooldownSecondsRemaining = polarBleManager?.cooldownSecondsRemaining?.collectAsState()?.value ?: 0 + val cooldownHrr60 = polarBleManager?.cooldownHrr60?.collectAsState()?.value + val cooldownHrr300 = polarBleManager?.cooldownHrr300?.collectAsState()?.value + 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 resolvedSessionId = sessionId ?: session?.id ?: activeCooldownSessionId ?: polarBleManager?.currentRecordingSessionId val fallbackSummary = remember(session, resolvedSessionId) { session?.let { s -> if (s.avgHeartRateBpm != null || s.maxHeartRateBpm != null || s.minHeartRateBpm != null || @@ -98,7 +97,7 @@ fun PolarSessionAnalyticsCard( maxTorsoRollDeg = s.maxTorsoRollDeg, ) } else null - } ?: resolvedSessionId?.let { polarBleManager.getSessionSummary(it) } + } ?: resolvedSessionId?.let { polarBleManager?.getSessionSummary(it) } } val rawSummary = if (resolvedSessionId != null && latestSummary?.sessionId == resolvedSessionId) { @@ -134,8 +133,8 @@ fun PolarSessionAnalyticsCard( } } - LaunchedEffect(resolvedSessionId) { - if (resolvedSessionId != null) { + LaunchedEffect(resolvedSessionId, workoutRepository) { + if (resolvedSessionId != null && workoutRepository != null) { try { beatSamples = workoutRepository.getPolarBeatSamplesBySession(resolvedSessionId) } catch (e: Exception) { @@ -165,7 +164,7 @@ fun PolarSessionAnalyticsCard( Card( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.large, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ), @@ -210,7 +209,7 @@ fun PolarSessionAnalyticsCard( Icon( imageVector = Icons.Default.Favorite, contentDescription = "Polar H10", - tint = Color.White, + tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(18.dp), ) } @@ -235,7 +234,7 @@ fun PolarSessionAnalyticsCard( } Surface( - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.small, color = if (isConnected) { MaterialTheme.colorScheme.primaryContainer } else { @@ -363,7 +362,7 @@ fun PolarSessionAnalyticsCard( if (showCooldown && (hasRecoveryData || isConnected)) { Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, colors = CardDefaults.cardColors( containerColor = if (isCooldownActive) { MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f) @@ -410,7 +409,7 @@ fun PolarSessionAnalyticsCard( val mins = cooldownSecondsRemaining / 60 val secs = cooldownSecondsRemaining % 60 Surface( - shape = RoundedCornerShape(6.dp), + shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.primary, ) { Text( @@ -454,7 +453,7 @@ fun PolarSessionAnalyticsCard( val isWarning = drop < 12 && cooldownSecondsRemaining <= 240 val tint = if (isWarning) Color(0xFFFF9800) else Color(0xFF4CAF50) Surface( - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.small, color = tint.copy(alpha = 0.15f), border = BorderStroke(1.dp, tint.copy(alpha = 0.4f)), ) { @@ -476,7 +475,7 @@ fun PolarSessionAnalyticsCard( modifier = Modifier .fillMaxWidth() .height(6.dp) - .clip(RoundedCornerShape(3.dp)), + .clip(MaterialTheme.shapes.extraSmall), color = MaterialTheme.colorScheme.primary, trackColor = MaterialTheme.colorScheme.surfaceVariant, ) @@ -487,7 +486,7 @@ fun PolarSessionAnalyticsCard( horizontalArrangement = Arrangement.End, ) { TextButton( - onClick = { polarBleManager.stopCooldown() }, + onClick = { polarBleManager?.stopCooldown() }, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), ) { Icon(Icons.Default.Close, contentDescription = null, modifier = Modifier.size(16.dp)) @@ -538,7 +537,7 @@ fun PolarSessionAnalyticsCard( ) { TextButton( onClick = { - resolvedSessionId?.let { polarBleManager.triggerCooldown(it, forceRestart = true) } + resolvedSessionId?.let { polarBleManager?.triggerCooldown(it, forceRestart = true) } }, ) { Icon( @@ -569,9 +568,9 @@ fun PolarSessionAnalyticsCard( ) FilledTonalButton( onClick = { - resolvedSessionId?.let { polarBleManager.triggerCooldown(it) } + resolvedSessionId?.let { polarBleManager?.triggerCooldown(it) } }, - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.small, contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp), modifier = Modifier.wrapContentWidth(), ) { @@ -624,7 +623,7 @@ private fun MetricTile( ) { Surface( modifier = modifier, - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainer, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)), ) { @@ -690,7 +689,7 @@ private fun RecoveryChip( val tint = if (isWarning) Color(0xFFFF9800) else Color(0xFF4CAF50) Surface( modifier = modifier, - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.small, color = tint.copy(alpha = 0.12f), border = BorderStroke(1.dp, tint.copy(alpha = 0.35f)), ) { @@ -757,7 +756,7 @@ private fun ContinuousHeartRateTimelineChart( Card( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)), ) { @@ -818,7 +817,7 @@ private fun ContinuousHeartRateTimelineChart( if (selectedSample != null) { val elapsedSec = ((selectedSample.timestampMs - minTs) / 1000).coerceAtLeast(0) Surface( - shape = RoundedCornerShape(6.dp), + shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.surfaceContainerHighest, modifier = Modifier.fillMaxWidth(), ) { @@ -871,7 +870,7 @@ private fun ContinuousHeartRateTimelineChart( modifier = Modifier .fillMaxWidth() .height(150.dp) - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.small) .background(Color(0xFF0F172A)) .pointerInput(Unit) { detectTapGestures( @@ -895,6 +894,8 @@ private fun ContinuousHeartRateTimelineChart( ) }, ) { + 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 @@ -1008,14 +1009,14 @@ private fun ContinuousHeartRateTimelineChart( scrubFraction?.let { fraction -> val scrubX = fraction * w drawLine( - color = Color.White.copy(alpha = 0.8f), + 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 = Color.White, radius = 4.dp.toPx(), center = Offset(scrubX, dotY)) + drawCircle(color = scrubberDotColor, radius = 4.dp.toPx(), center = Offset(scrubX, dotY)) drawCircle(color = Color(0xFFFF5252), radius = 2.5.dp.toPx(), center = Offset(scrubX, dotY)) } } @@ -1080,7 +1081,7 @@ private fun TorsoBiomechanicsVsCableChart( Card( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)), ) { @@ -1152,7 +1153,7 @@ private fun TorsoBiomechanicsVsCableChart( selectedSample.position?.toFloat() ?: selectedSample.positionB?.toFloat() } Surface( - shape = RoundedCornerShape(6.dp), + shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.surfaceContainerHighest, modifier = Modifier.fillMaxWidth(), ) { @@ -1196,7 +1197,7 @@ private fun TorsoBiomechanicsVsCableChart( modifier = Modifier .fillMaxWidth() .height(150.dp) - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.small) .background(Color(0xFF0D1B2A)) .pointerInput(Unit) { detectTapGestures( @@ -1220,6 +1221,8 @@ private fun TorsoBiomechanicsVsCableChart( ) }, ) { + 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 @@ -1337,14 +1340,14 @@ private fun TorsoBiomechanicsVsCableChart( scrubFraction?.let { fraction -> val scrubX = fraction * w drawLine( - color = Color.White.copy(alpha = 0.8f), + 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 = Color.White, radius = 4.dp.toPx(), center = Offset(scrubX, pitchY)) + 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) { @@ -1354,7 +1357,7 @@ private fun TorsoBiomechanicsVsCableChart( } selCablePos?.let { pos -> val cableY = padTop + (1f - (pos - minPos) / posRange) * plotH - drawCircle(color = Color.White, radius = 4.dp.toPx(), center = Offset(scrubX, cableY)) + drawCircle(color = scrubberDotColor, radius = 4.dp.toPx(), center = Offset(scrubX, cableY)) drawCircle(color = Color(0xFFAB47BC), radius = 2.5.dp.toPx(), center = Offset(scrubX, cableY)) } } @@ -1401,7 +1404,7 @@ private fun SavedEcgWaveformScrubber( Card( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)), ) { @@ -1435,7 +1438,7 @@ private fun SavedEcgWaveformScrubber( } Surface( - shape = RoundedCornerShape(4.dp), + shape = MaterialTheme.shapes.extraSmall, color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f), ) { Text( @@ -1494,9 +1497,9 @@ private fun SavedEcgWaveformScrubber( modifier = Modifier .fillMaxWidth() .height(140.dp) - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.small) .background(Color(0xFF081422)) - .border(0.5.dp, Color(0xFF1B3A5A), RoundedCornerShape(8.dp)), + .border(0.5.dp, Color(0xFF1B3A5A), MaterialTheme.shapes.small), ) { val windowSlice by produceState>( initialValue = emptyList(), diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtils.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtils.kt deleted file mode 100644 index e489e1213..000000000 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/PolarTelemetryUtils.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.devil.phoenixproject.presentation.util - -import com.devil.phoenixproject.domain.model.PolarRrBeat -import com.devil.phoenixproject.util.PolarTelemetryUtils - -/** - * 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 [PolarTelemetryUtils.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 PolarTelemetryUtils.calculateRmssdFromBeats(windowBeats) -} - -/** - * Format milliseconds to a clean integer string representation. - */ -fun formatRrMs(rrMs: Double?): String = PolarTelemetryUtils.formatRrMs(rrMs) - -/** - * Format RMSSD to a clean integer string representation. - */ -fun formatRmssdMs(rmssd: Double?): String = PolarTelemetryUtils.formatRmssdMs(rmssd) - 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 839930dfe..aa3297516 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 @@ -502,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, @@ -544,22 +544,9 @@ class MainViewModel( internal val machineSafetyCoordinator: MachineSafetyCoordinator, private val profileRecoveryActivityTracker: ProfileRecoveryActivityTracker? = null, private val recentJustLiftExerciseStore: RecentJustLiftExerciseStore? = null, - private val polarBleManager: PolarH10BleManager? = null, + val polarBleManager: PolarH10BleManager? = null, ) : ViewModel() { - init { - polarBleManager?.let { polar -> - viewModelScope.launch { - polar.isConnected.collect { connected -> - if (!connected && polar.isCooldownActive.value) { - Logger.w { "Polar H10 disconnected during recovery cooldown: terminating recovery window" } - polar.cancelCooldown() - } - } - } - } - } - // Shared haptic events flow - created here, passed to both GamificationManager and WorkoutSessionManager private val _hapticEvents = MutableSharedFlow( extraBufferCapacity = 32, 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 17097f7a3..9f6b4c1b8 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt @@ -444,22 +444,16 @@ 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()) } } - val polarBeatSamples = if (includeRawTelemetryInBackups) { - queries.selectAllBackupPolarBeatSamples().executeAsList() - } else { - emptyList() - } - val polarEcgSegments = if (includeRawTelemetryInBackups) { - queries.selectAllBackupPolarEcgSegments().executeAsList() - } else { - emptyList() - } // Full backups retain routine tombstones because historical telemetry keeps // foreign-key links to the original routine and routine exercise IDs. @@ -3247,21 +3241,41 @@ abstract class BaseDataBackupManager( }) writer.write(",") - val polarBeatSamples = if (includeRawTelemetryInBackups) { - queries.selectAllBackupPolarBeatSamples().executeAsList() - } else { - emptyList() + 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() + } + } } - writeJsonArray(writer, "polarBeatSamples", polarBeatSamples.map { json.encodeToString(PolarBeatSampleBackup.serializer(), mapPolarBeatSampleToBackup(it)) }) - writer.write(",") + writer.write("],") + writer.flush() - val polarEcgSegments = if (includeRawTelemetryInBackups) { - queries.selectAllBackupPolarEcgSegments().executeAsList() - } else { - emptyList() + 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() + } + } } - writeJsonArray(writer, "polarEcgSegments", polarEcgSegments.map { json.encodeToString(PolarEcgSegmentBackup.serializer(), mapPolarEcgSegmentToBackup(it)) }) - writer.write(",") + writer.write("],") + writer.flush() val progressionEvents = queries.selectAllProgressionEventsSync().executeAsList() writeJsonArray(writer, "progressionEvents", progressionEvents.map { json.encodeToString(ProgressionEventBackup.serializer(), mapProgressionEventToBackup(it)) }) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt index 56f832750..16c1e5ea5 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt @@ -119,14 +119,38 @@ object PolarTelemetryUtils { ) } + /** + * 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" } ?: "—" - fun formatRrMs(rrMs: Double?): String = formatMs(rrMs) - fun formatRmssdMs(rmssd: Double?): String = formatMs(rmssd) } +/** + * Computes rolling RMSSD (Root Mean Square of Successive Differences) in milliseconds + * over a sliding time window (default 60 seconds). + */ +fun computeRollingRmssd( + beats: List, + windowMs: Long = 60_000L, + currentTimeMs: Long, +): Double? = PolarTelemetryUtils.computeRollingRmssd(beats, windowMs, currentTimeMs) + /** * Stateful exponential moving average (EMA) low-pass filter for 50 Hz Polar ACC stream. */ diff --git a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/58.sqm b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/58.sqm index 22da829ab..3d72fd511 100644 --- a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/58.sqm +++ b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/58.sqm @@ -15,56 +15,3 @@ ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_server_revision I ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_dirty INTEGER NOT NULL DEFAULT 0; --- Migration 58: 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/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/PolarH10BleManagerTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt index fe608917c..828d18f90 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt @@ -79,11 +79,8 @@ class PolarH10BleManagerTest { assertTrue(manager.scannedDevices.value.isEmpty()) assertNull(manager.batteryLevel.value) assertNull(manager.hrMeasurement.value) - assertNull(manager.rrClockTimestampMs.value) assertNull(manager.latestEcg.value) assertNull(manager.latestAcc.value) - assertFalse(manager.isEcgActive) - assertFalse(manager.isAccActive) } // ==================== Chained RR-Interval Clock Tests ==================== @@ -120,7 +117,6 @@ class PolarH10BleManagerTest { assertEquals(850.0, collectedBeats[1].rrIntervalMs) assertEquals(100_000L, collectedBeats[1].timestampMs) assertEquals(72, collectedBeats[1].hr) - assertEquals(100_000L, manager.rrClockTimestampMs.value) // Packet 2 arrives at t = 100,920 ms (packet transport delayed by 20ms jitter) // Previous beat was at 100,000 ms. @@ -138,7 +134,6 @@ class PolarH10BleManagerTest { assertEquals(900.0, collectedBeats[2].rrIntervalMs) assertEquals(100_900L, collectedBeats[2].timestampMs) assertEquals(70, collectedBeats[2].hr) - assertEquals(100_900L, manager.rrClockTimestampMs.value) collectJob.cancel() testScope.cancel() @@ -179,17 +174,19 @@ class PolarH10BleManagerTest { // 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) - assertEquals(65_000L, manager.rrClockTimestampMs.value) collectJob.cancel() testScope.cancel() } @Test - fun `processRrIntervals ignores empty RR lists without modifying clock`() { + 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())) - assertNull(manager.rrClockTimestampMs.value) + assertTrue(beats.isEmpty()) + job.cancel() } // ==================== Session Recording & Cooldown Tests ==================== @@ -408,7 +405,6 @@ class PolarH10BleManagerTest { ) assertEquals(1, collectedBeats.size) assertEquals(135, manager.hrMeasurement.value?.hr) - assertEquals(100_000L, manager.rrClockTimestampMs.value) // 2. User removes strap (contactDetected = false, hr = 0) simulatedTime += 1000L @@ -423,7 +419,6 @@ class PolarH10BleManagerTest { // 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") - assertNull(manager.rrClockTimestampMs.value, "rrClockTimestampMs should be cleared upon strap removal") collectJob.cancel() testScope.cancel() @@ -449,6 +444,7 @@ class PolarH10BleManagerTest { 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) @@ -605,6 +601,49 @@ class PolarH10BleManagerTest { 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 state + manager.stopCooldown() + assertFalse(manager.isCooldownActive.value) + assertNull(manager.activeCooldownSessionId.value) + assertNull(manager.currentRecordingSessionId) + + // Once cooldown ended, further queries reject + assertNull(manager.getSessionSummary("session-A")) + + testScope.cancel() + } } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/PolarH10WiringTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/PolarH10WiringTest.kt deleted file mode 100644 index 7a3843f04..000000000 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/PolarH10WiringTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.devil.phoenixproject.presentation - -import com.devil.phoenixproject.testutil.readProjectFile -import kotlin.test.Test -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class PolarH10WiringTest { - - @Test - fun settingsTabIncludesPolarH10SettingsCard() { - val src = readProjectFile("src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt") - assertNotNull(src, "SettingsTab.kt must be readable") - assertTrue( - src.contains("import com.devil.phoenixproject.presentation.components.PolarH10SettingsCard"), - "SettingsTab.kt must import PolarH10SettingsCard", - ) - assertTrue( - src.contains("PolarH10SettingsCard()"), - "SettingsTab.kt must render PolarH10SettingsCard", - ) - } - - @Test - fun activeWorkoutScreenIncludesPolarH10WorkoutStrip() { - val src = readProjectFile("src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ActiveWorkoutScreen.kt") - assertNotNull(src, "ActiveWorkoutScreen.kt must be readable") - assertTrue( - src.contains("import com.devil.phoenixproject.presentation.components.PolarH10WorkoutStrip"), - "ActiveWorkoutScreen.kt must import PolarH10WorkoutStrip", - ) - assertTrue( - src.contains("PolarH10WorkoutStrip()"), - "ActiveWorkoutScreen.kt must render PolarH10WorkoutStrip", - ) - } -} 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 index aa1906f9f..49618bf98 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarRollingTelemetryUtilsTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarRollingTelemetryUtilsTest.kt @@ -1,6 +1,8 @@ package com.devil.phoenixproject.presentation.util import com.devil.phoenixproject.domain.model.PolarRrBeat +import com.devil.phoenixproject.util.PolarTelemetryUtils +import com.devil.phoenixproject.util.computeRollingRmssd import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -29,7 +31,7 @@ class PolarRollingTelemetryUtilsTest { val rmssd = computeRollingRmssd(beats, windowMs = 60_000L, currentTimeMs = now) assertTrue(rmssd != null) assertEquals(41.23, rmssd, 0.01) - assertEquals("41 ms", formatRmssdMs(rmssd)) + assertEquals("41 ms", PolarTelemetryUtils.formatMs(rmssd)) } @Test 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 917f89cea..1537d176a 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 @@ -549,7 +549,7 @@ fun ActiveWorkoutScreen( } // Polar H10 Live Telemetry Strip (visible when connected) - PolarH10WorkoutStrip() + PolarH10WorkoutStrip(polarBleManager = viewModel.polarBleManager) WorkoutTab( state = workoutUiState, @@ -561,6 +561,7 @@ fun ActiveWorkoutScreen( personalRecordRepository = viewModel.personalRecordRepository, userPreferences = userPreferences, customEquipmentOverlay = customEquipmentOverlay, + polarBleManager = viewModel.polarBleManager, ) } } 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..b2ffc6487 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,7 @@ fun AnalyticsScreen( }, recentJustLiftExerciseIds = recentJustLiftExerciseIds, customEquipmentOverlay = customEquipmentOverlay, + polarBleManager = viewModel.polarBleManager, modifier = Modifier.fillMaxSize(), ) } 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 61dde0236..16ee992bf 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,6 +52,7 @@ 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 @@ -133,6 +134,7 @@ fun HistoryTab( recentJustLiftExerciseIds: List = emptyList(), // Issue #1227: TOKEN -> LABEL custom-equipment overlay for the picker dialogs. customEquipmentOverlay: Map = emptyMap(), + polarBleManager: PolarH10BleManager? = null, modifier: Modifier = Modifier, ) { // M8: Hoist koinInject calls to parent composable scope (outside LazyColumn items). @@ -229,6 +231,7 @@ fun HistoryTab( onClearJustLiftSessionExercise = onClearJustLiftSessionExercise, recentJustLiftExerciseIds = recentJustLiftExerciseIds, customEquipmentOverlay = customEquipmentOverlay, + polarBleManager = polarBleManager, onDelete = { onDeleteWorkout(item.session.id) }, ) } @@ -245,6 +248,7 @@ fun HistoryTab( onClearJustLiftSessionExercise = onClearJustLiftSessionExercise, recentJustLiftExerciseIds = recentJustLiftExerciseIds, customEquipmentOverlay = customEquipmentOverlay, + polarBleManager = polarBleManager, // Issue #591 follow-up: thread the // routine-level delete callback so the // History "Delete All Sets" path also @@ -275,6 +279,7 @@ private fun WorkoutHistoryCard( recentJustLiftExerciseIds: List = emptyList(), // Issue #1227: TOKEN -> LABEL custom-equipment overlay for the tag picker. customEquipmentOverlay: Map = emptyMap(), + polarBleManager: PolarH10BleManager? = null, onDelete: () -> Unit, ) { val scope = rememberCoroutineScope() @@ -497,6 +502,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. @@ -567,6 +573,8 @@ private fun WorkoutHistoryCard( PolarSessionAnalyticsCard( sessionId = session.id, session = session, + polarBleManager = polarBleManager, + showCooldown = false, ) } } @@ -833,6 +841,7 @@ private fun GroupedRoutineCard( recentJustLiftExerciseIds: List = emptyList(), // Issue #1227: TOKEN -> LABEL custom-equipment overlay for the tag picker. customEquipmentOverlay: Map = emptyMap(), + polarBleManager: PolarH10BleManager? = 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`). @@ -1117,6 +1126,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, @@ -1194,6 +1204,8 @@ private fun GroupedRoutineCard( PolarSessionAnalyticsCard( sessionId = polarSession.id, session = polarSession, + polarBleManager = polarBleManager, + 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 f08ea5356..b61610088 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,6 +24,7 @@ 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 @@ -47,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 @@ -258,6 +263,8 @@ fun RoutineCompleteScreen(navController: NavController, viewModel: MainViewModel // Polar H10 Session Analytics & 5-minute recovery cooldown PolarSessionAnalyticsCard( modifier = Modifier.fillMaxWidth(), + polarBleManager = polarBleManager, + workoutRepository = viewModel.workoutRepository, ) } 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 ab7417b89..40b2e29c9 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 @@ -47,7 +47,6 @@ import com.devil.phoenixproject.ui.theme.velocityZoneLabel import com.devil.phoenixproject.data.ble.PolarH10BleManager import com.devil.phoenixproject.presentation.components.PolarSessionAnalyticsCard import com.devil.phoenixproject.util.KmpUtils -import org.koin.compose.koinInject import kotlin.math.cos import kotlin.math.roundToInt import kotlin.math.sin @@ -111,11 +110,11 @@ 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 = koinInject(), + polarBleManager: PolarH10BleManager? = null, ) { // State for RPE tracking var loggedRpe by remember { mutableStateOf(null) } - val isCooldownActive by polarBleManager.isCooldownActive.collectAsState() + 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. @@ -1040,7 +1039,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/WorkoutTab.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt index 02f13d10a..5a3d4893a 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,7 @@ 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.presentation.components.PolarSessionAnalyticsCard import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -143,6 +144,7 @@ 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, ) { val connectionState = state.connectionState val workoutState = state.workoutState @@ -298,6 +300,7 @@ fun WorkoutTab( onStartNextExercise = onStartNextExercise, onResetForNewWorkout = onResetForNewWorkout, machineTeardownState = machineTeardownState, + polarBleManager = polarBleManager, ) } @@ -424,6 +427,7 @@ fun WorkoutTab( workoutMode = workoutParameters.programMode.displayName, weightUnit = weightUnit, kgToDisplay = kgToDisplay, + polarBleManager = polarBleManager, onContinue = { // Intercept dismissal once to nudge tagging of an untagged // Just Lift set. If the user already decided, or the set isn't a @@ -942,6 +946,7 @@ private fun CompletedCard( onStartNextExercise: () -> Unit, onResetForNewWorkout: () -> Unit, machineTeardownState: MachineTeardownState, + polarBleManager: PolarH10BleManager? = null, ) { // workout-setup-16: spring scaleIn entrance on the CheckCircle icon. // reduceMotion: EnterTransition.None — icon appears instantly (complete static final state). @@ -1032,7 +1037,10 @@ private fun CompletedCard( } // Polar H10 Session Analytics & Cooldown Protocol (rendered below exercise cards, above action button) - PolarSessionAnalyticsCard(modifier = Modifier.padding(vertical = Spacing.small)) + PolarSessionAnalyticsCard( + modifier = Modifier.padding(vertical = Spacing.small), + polarBleManager = polarBleManager, + ) if (hasMoreExercises && nextExercise != null) { Button( From d2624ee1bcd0ffea5fb64354939032ebe7bcb58e Mon Sep 17 00:00:00 2001 From: StuGotz Date: Sun, 25 Oct 2026 21:48:29 -0500 Subject: [PATCH 16/25] fix(polar): serialize PMD stream startup for Set 1 ECG and fix post-workout HRR button --- .../viewmodel/ConnectionLogsViewModelTest.kt | 27 ++ .../data/ble/PolarH10BleManager.kt | 351 ++++++++++++++++-- .../data/ble/PolarProtocolParser.kt | 12 +- .../devil/phoenixproject/data/local/Stubs.kt | 1 + .../repository/ConnectionLogRepository.kt | 49 ++- .../components/PolarH10SettingsCard.kt | 112 +++++- .../components/PolarH10WorkoutStrip.kt | 2 +- .../components/PolarSessionAnalyticsCard.kt | 17 +- .../manager/DefaultWorkoutSessionManager.kt | 3 +- .../viewmodel/ConnectionLogsViewModel.kt | 17 +- .../data/ble/PolarH10BleManagerTest.kt | 161 +++++++- .../data/ble/PolarProtocolParserTest.kt | 25 +- .../screen/ConnectionLogsScreen.kt | 24 ++ .../screen/RoutineCompleteScreen.kt | 1 + 14 files changed, 739 insertions(+), 63 deletions(-) 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/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt index 4dc29ca2c..f79367502 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -1,6 +1,8 @@ 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.database.PhoenixDatabase import com.devil.phoenixproject.domain.model.ConnectionState @@ -80,6 +82,7 @@ class PolarH10BleManager( 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 log = Logger.withTag("PolarH10BleManager") @@ -201,6 +204,9 @@ class PolarH10BleManager( 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() @@ -223,6 +229,7 @@ class PolarH10BleManager( completedCooldownSessionId = null activeSessionId = sessionId recordedSessionId = sessionId + _lastRecordedSessionId.value = sessionId currentPhase = phase sessionLock.withLock { bufferedBeatSamples.clear() @@ -241,8 +248,10 @@ class PolarH10BleManager( _latestSessionSummary.value = null if (_connectionState.value is ConnectionState.Connected) { - if (!isAccStreaming) scope.launch { startAccStream() } - if (!isEcgStreaming) scope.launch { startEcgStream() } + scope.launch { + if (!_isEcgStreaming.value) startEcgStream() + if (!_isAccStreaming.value) startAccStream() + } } } @@ -257,6 +266,8 @@ class PolarH10BleManager( committedSessionIds.add(fallbackSessionId) } activeSessionId = sessionId + recordedSessionId = sessionId + _lastRecordedSessionId.value = sessionId val beats = bufferedBeatSamples.toList() bufferedBeatSamples.clear() val ecgs = bufferedEcgSegments.toList() @@ -370,16 +381,24 @@ class PolarH10BleManager( } fun triggerCooldown(sessionId: String? = null, forceRestart: Boolean = false) { - val targetId: String = sessionId ?: recordedSessionId ?: (if (forceRestart) completedCooldownSessionId else null) ?: return - if (targetId != recordedSessionId && (!forceRestart || targetId != completedCooldownSessionId)) return + val knownRecordedId = recordedSessionId ?: _lastRecordedSessionId.value + val targetId: String = sessionId + ?: knownRecordedId + ?: (if (forceRestart) completedCooldownSessionId else null) + ?: return + if (targetId != knownRecordedId && (!forceRestart || targetId != completedCooldownSessionId)) return if (forceRestart && targetId == completedCooldownSessionId) { - recordedSessionId = targetId - activeSessionId = targetId + completedCooldownSessionIds.remove(targetId) } + recordedSessionId = targetId + activeSessionId = targetId + _lastRecordedSessionId.value = targetId endSessionRecording(targetId, forceRestart = forceRestart) if (_connectionState.value is ConnectionState.Connected) { - if (!isAccStreaming) scope.launch { startAccStream() } - if (!isEcgStreaming) scope.launch { startEcgStream() } + scope.launch { + if (!_isEcgStreaming.value) startEcgStream() + if (!_isAccStreaming.value) startAccStream() + } } } @@ -413,6 +432,7 @@ class PolarH10BleManager( activeSessionId = null _activeCooldownSessionId.value = null recordedSessionId = null + _lastRecordedSessionId.value = null scope.launch { stopEcgStream() stopAccStream() @@ -420,11 +440,14 @@ class PolarH10BleManager( } fun endSessionRecording(sessionId: String? = null, forceRestart: Boolean = false) { - val targetSessionId: String = sessionId ?: recordedSessionId ?: (if (forceRestart) completedCooldownSessionId else null) ?: return - if (targetSessionId != recordedSessionId && (!forceRestart || targetSessionId != completedCooldownSessionId)) return + val knownRecordedId = recordedSessionId ?: _lastRecordedSessionId.value + val targetSessionId: String = sessionId + ?: knownRecordedId + ?: (if (forceRestart) completedCooldownSessionId else null) + ?: return + if (targetSessionId != knownRecordedId && (!forceRestart || targetSessionId != completedCooldownSessionId)) return if (forceRestart && targetSessionId == completedCooldownSessionId) { - recordedSessionId = targetSessionId - activeSessionId = targetSessionId + completedCooldownSessionIds.remove(targetSessionId) } if (!forceRestart && completedCooldownSessionIds.contains(targetSessionId)) { Logger.d { "endSessionRecording: Cooldown already completed for session $targetSessionId; skipping" } @@ -437,9 +460,9 @@ class PolarH10BleManager( if (forceRestart) { completedCooldownSessionIds.remove(targetSessionId) } - if (activeSessionId == null) { - activeSessionId = targetSessionId - } + recordedSessionId = targetSessionId + activeSessionId = targetSessionId + _lastRecordedSessionId.value = targetSessionId _activeCooldownSessionId.value = targetSessionId val lastHr = sessionLock.withLock { sessionHrs.lastOrNull() } endHeartRate = _hrMeasurement.value?.hr ?: lastHr @@ -519,6 +542,7 @@ class PolarH10BleManager( activeSessionId = null _activeCooldownSessionId.value = null recordedSessionId = null + _lastRecordedSessionId.value = null stopEcgStream() stopAccStream() } @@ -623,6 +647,22 @@ class PolarH10BleManager( } } + 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() @@ -668,7 +708,7 @@ class PolarH10BleManager( // ===== PMD Stream Control ===== private val pmdMutex = Mutex() - private val pmdControlPointMutex = Mutex() + private val pmdCommandMutex = Mutex() private val pmdCpResponses = MutableSharedFlow( replay = 0, extraBufferCapacity = 16, @@ -676,8 +716,25 @@ class PolarH10BleManager( ) private var pmdControlPointJob: Job? = null private var pmdDataJob: Job? = null - private var isEcgStreaming = false - private var isAccStreaming = false + + 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 @@ -689,6 +746,11 @@ class PolarH10BleManager( 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", + ) scanJob?.cancel() scanJob = null _scannedDevices.value = emptyList() @@ -724,6 +786,11 @@ class PolarH10BleManager( } 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) { @@ -747,6 +814,11 @@ class PolarH10BleManager( 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 } } @@ -843,6 +915,13 @@ class PolarH10BleManager( 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) @@ -857,6 +936,13 @@ class PolarH10BleManager( is State.Disconnected -> { _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() } } @@ -872,6 +958,14 @@ class PolarH10BleManager( } catch (e: Exception) { 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", + ) _isConnected.value = false cleanupExistingConnection() _connectionState.value = ConnectionState.Error("Connection failed: ${e.message}", e) @@ -942,8 +1036,8 @@ class PolarH10BleManager( } if (activeSessionId != null) { - if (!isAccStreaming) scope.launch { startAccStream() } - if (!isEcgStreaming) scope.launch { startEcgStream() } + if (!_isAccStreaming.value) scope.launch { startAccStream() } + if (!_isEcgStreaming.value) scope.launch { startEcgStream() } } } @@ -952,6 +1046,11 @@ class PolarH10BleManager( 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() @@ -962,6 +1061,21 @@ class PolarH10BleManager( 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." } + if (!measurement.contactDetected) { + logRepo.warning( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] Poor skin contact detected (contact=${measurement.contactDetected}, hr=${measurement.hr})", + tag = "HRM", + ) + } + if (previousContactDetected == true) { + previousContactDetected = false + logRepo.debug( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] Contact lost (contact: true -> false)", + tag = "HRM", + ) + } _isContactDetected.value = false clearCachedTelemetry() if (_isCooldownActive.value) { @@ -971,6 +1085,15 @@ class PolarH10BleManager( 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 lastValidPacketTimestampMs = monotonicClock() _hrMeasurement.value = measurement @@ -996,6 +1119,21 @@ class PolarH10BleManager( return } if (!measurement.contactDetected || measurement.hr <= 0 || isBatteryCritical()) { + if (!measurement.contactDetected) { + logRepo.warning( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] Poor skin contact detected (contact=${measurement.contactDetected}, hr=${measurement.hr})", + tag = "HRM", + ) + } + if (previousContactDetected == true) { + previousContactDetected = false + logRepo.debug( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] Contact lost (contact: true -> false)", + tag = "HRM", + ) + } _isContactDetected.value = false clearCachedTelemetry() if (_isCooldownActive.value) { @@ -1003,6 +1141,14 @@ class PolarH10BleManager( } 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 lastValidPacketTimestampMs = monotonicClock() _hrMeasurement.value = measurement @@ -1146,21 +1292,27 @@ class PolarH10BleManager( } if (!_isContactDetected.value || isBatteryCritical()) { log.w { "Polar H10 PMD packet discarded: zero contact or critical battery (contact=${_isContactDetected.value}, batt=${_batteryLevel.value})" } - clearCachedTelemetry() 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 ECG segment and clearing telemetry." } - _isContactDetected.value = false - clearCachedTelemetry() + log.w { "Polar H10 ECG flatline/railing open-circuit noise detected (flatline=$isFlatlineOrZero, railed=$isRailed). Dropping transient frame." } + _latestEcg.value = null return } lastValidPacketTimestampMs = monotonicClock() @@ -1184,6 +1336,14 @@ class PolarH10BleManager( } } 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 @@ -1221,7 +1381,7 @@ class PolarH10BleManager( opCode: Byte, measurementType: Byte, timeoutMs: Long = 2000L, - ): Result = pmdControlPointMutex.withLock { + ): Result = pmdCommandMutex.withLock { ensurePmdSubscribed(p) // Dwell 50ms before writing to ensure Android GATT queue is clean delay(50L) @@ -1251,9 +1411,30 @@ class PolarH10BleManager( val status = withTimeoutOrNull(timeoutMs) { responseDeferred.await() } ?: 0 // If timeout waiting for indication, assume write succeeded + 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() @@ -1268,6 +1449,12 @@ class PolarH10BleManager( 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, @@ -1277,11 +1464,40 @@ class PolarH10BleManager( if (status != 0) { log.w { "Polar H10 returned non-zero status $status starting ECG stream" } } - isEcgStreaming = true + _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) } } @@ -1293,17 +1509,30 @@ class PolarH10BleManager( 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, ) - isEcgStreaming = false + _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) } } @@ -1317,6 +1546,12 @@ class PolarH10BleManager( 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, @@ -1347,14 +1582,25 @@ class PolarH10BleManager( 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 = true + _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) } } @@ -1366,17 +1612,34 @@ class PolarH10BleManager( 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, ) - isAccStreaming = false + _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) } } @@ -1410,6 +1673,11 @@ class PolarH10BleManager( 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)" } @@ -1438,11 +1706,16 @@ class PolarH10BleManager( 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) { + if (_isEcgStreaming.value) { try { stopEcgStream() } catch (_: Exception) {} } - if (isAccStreaming) { + if (_isAccStreaming.value) { try { stopAccStream() } catch (_: Exception) {} } p.disconnect() @@ -1469,8 +1742,8 @@ class PolarH10BleManager( watchdogJob?.cancel() watchdogJob = null - isEcgStreaming = false - isAccStreaming = false + _isEcgStreaming.value = false + _isAccStreaming.value = false _isConnected.value = false _batteryLevel.value = null @@ -1482,6 +1755,14 @@ class PolarH10BleManager( } internal fun clearCachedTelemetry() { + 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 @@ -1512,10 +1793,10 @@ class PolarH10BleManager( if (p != null) { scope.launch { try { - if (isEcgStreaming) { + if (_isEcgStreaming.value) { try { stopEcgStream() } catch (_: Exception) {} } - if (isAccStreaming) { + if (_isAccStreaming.value) { try { stopAccStream() } catch (_: Exception) {} } p.disconnect() 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 index e9d88fe80..26cc2cb30 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParser.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParser.kt @@ -70,7 +70,6 @@ fun parsePolarHrMeasurement(data: ByteArray): PolarHrMeasurement? { val flags = data[0].toInt() and 0xFF val hrFormat16Bit = (flags and 0x01) != 0 - val contactDetected = (flags and 0x06) == 0x06 val energyPresent = (flags and 0x08) != 0 val rrPresent = (flags and 0x10) != 0 @@ -86,6 +85,17 @@ fun parsePolarHrMeasurement(data: ByteArray): PolarHrMeasurement? { 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 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/presentation/components/PolarH10SettingsCard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt index 9deed85d8..5556b7c12 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt @@ -42,7 +42,12 @@ 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.foundation.border +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.draw.clip 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 @@ -73,6 +78,23 @@ fun PolarH10SettingsCard( val torsoPitchDeg by polarBleManager.torsoPitchDeg.collectAsState() val torsoRollDeg by polarBleManager.torsoRollDeg.collectAsState() val dynamicAccMg by polarBleManager.dynamicAccMg.collectAsState() + val isContactDetected by polarBleManager.isContactDetected.collectAsState() + val isAccStreaming by polarBleManager.isAccStreaming.collectAsState() + val isEcgStreaming by polarBleManager.isEcgStreaming.collectAsState() + + val ecgSamples = remember { mutableStateListOf() } + LaunchedEffect(isEcgStreaming) { + if (!isEcgStreaming) { + ecgSamples.clear() + } else { + polarBleManager.ecgStream.collect { ecg -> + ecgSamples.addAll(ecg.samplesMicrovolts) + while (ecgSamples.size > 260) { + ecgSamples.removeAt(0) + } + } + } + } @@ -345,7 +367,7 @@ fun PolarH10SettingsCard( verticalAlignment = Alignment.CenterVertically, ) { Row(verticalAlignment = Alignment.CenterVertically) { - val contact = hrMeasurement?.contactDetected == true + val contact = isContactDetected Icon( if (contact) Icons.Default.CheckCircle else Icons.Default.Warning, contentDescription = null, @@ -390,6 +412,94 @@ fun PolarH10SettingsCard( onCheckedChange = { polarBleManager.setAutoReconnectEnabled(it) }, ) } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "50 Hz ACC Stream", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + "Stream live chest accelerometer & posture", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = isAccStreaming, + onCheckedChange = { enable -> + scope.launch { + if (enable) polarBleManager.startAccStream() else polarBleManager.stopAccStream() + } + }, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "130 Hz ECG Stream", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + "Stream live single-lead electrocardiogram trace", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = isEcgStreaming, + onCheckedChange = { enable -> + scope.launch { + if (enable) polarBleManager.startEcgStream() else polarBleManager.stopEcgStream() + } + }, + ) + } + + AnimatedVisibility(visible = isEcgStreaming) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + .clip(MaterialTheme.shapes.small) + .background(Color(0xFF0D1B2A)) + .border(1.dp, Color(0xFF1E3A5F), MaterialTheme.shapes.small), + contentAlignment = Alignment.Center, + ) { + if (ecgSamples.isEmpty()) { + Text( + text = if (isContactDetected) "Waiting for ECG signal..." else "Ensure strap electrodes are moist and snug against skin", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + ) + } + EcgWaveformCanvas( + samples = ecgSamples, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(horizontal = 4.dp, vertical = 4.dp), + ) + } + } + } } // Torso Biomechanics Posture Section diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt index 2f535839e..c1a80d3fb 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt @@ -341,7 +341,7 @@ private fun PolarH10WorkoutStripContent( * Real-time hardware-accelerated Canvas waveform plotting 130 Hz ECG samples (µV). */ @Composable -private fun EcgWaveformCanvas( +internal fun EcgWaveformCanvas( samples: List, modifier: Modifier = Modifier, ) { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt index fa3e521c6..b0ac9e588 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt @@ -77,8 +77,14 @@ fun PolarSessionAnalyticsCard( val latestSummary = polarBleManager?.latestSessionSummary?.collectAsState()?.value val hrMeasurement = polarBleManager?.hrMeasurement?.collectAsState()?.value val activeCooldownSessionId = polarBleManager?.activeCooldownSessionId?.collectAsState()?.value - - val resolvedSessionId = sessionId ?: session?.id ?: activeCooldownSessionId ?: polarBleManager?.currentRecordingSessionId + val lastRecordedSessionId = polarBleManager?.lastRecordedSessionId?.collectAsState()?.value + + val resolvedSessionId = sessionId + ?: session?.id + ?: activeCooldownSessionId + ?: polarBleManager?.currentRecordingSessionId + ?: latestSummary?.sessionId + ?: lastRecordedSessionId val fallbackSummary = remember(session, resolvedSessionId) { session?.let { s -> if (s.avgHeartRateBpm != null || s.maxHeartRateBpm != null || s.minHeartRateBpm != null || @@ -568,7 +574,12 @@ fun PolarSessionAnalyticsCard( ) FilledTonalButton( onClick = { - resolvedSessionId?.let { polarBleManager?.triggerCooldown(it) } + val targetId = resolvedSessionId + ?: polarBleManager?.lastRecordedSessionId?.value + ?: polarBleManager?.latestSessionSummary?.value?.sessionId + if (targetId != null) { + polarBleManager?.triggerCooldown(targetId) + } }, shape = MaterialTheme.shapes.small, contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp), 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 3bd670f36..08a67287b 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 @@ -540,7 +540,8 @@ class DefaultWorkoutSessionManager( polarBleManager.updateSessionPhase("REST") } is WorkoutState.Idle -> { - if (!polarBleManager.isCooldownActive.value) { + if (!polarBleManager.isCooldownActive.value && + coordinator.routineFlowState.value !is RoutineFlowState.Complete) { polarBleManager.discardActiveRecording() } } 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/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt index 828d18f90..10ed12d85 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt @@ -529,13 +529,14 @@ class PolarH10BleManagerTest { } @Test - fun `ECG railing beyond physiological threshold triggers contact loss and clears telemetry`() { + 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( @@ -545,15 +546,25 @@ class PolarH10BleManagerTest { ) ) - assertFalse(manager.isContactDetected.value, "Railed ECG must trip contact loss") - assertNull(manager.latestEcg.value, "Railed ECG must be discarded") - assertNull(manager.hrMeasurement.value, "Telemetry must be cleared upon ECG railing") + // 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 triggers contact loss and drops packet`() { + fun `ECG flatline drops transient frame without revoking contact`() { val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) val manager = PolarH10BleManager(scope = testScope) @@ -569,8 +580,51 @@ class PolarH10BleManagerTest { ) ) - assertFalse(manager.isContactDetected.value, "Flatline ECG must trip contact loss") 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() } @@ -644,6 +698,101 @@ class PolarH10BleManagerTest { 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() + } } 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 index eec64caf4..a7b91ec31 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParserTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParserTest.kt @@ -38,15 +38,30 @@ class PolarProtocolParserTest { @Test fun `parsePolarHrMeasurement handles contact status correctly`() { - // Flags: 0x04 (contact supported but NOT detected) + // 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) - val notSupported = parsePolarHrMeasurement(byteArrayOf(0x00, 60)) - assertNotNull(notSupported) - assertFalse(notSupported.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 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/RoutineCompleteScreen.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineCompleteScreen.kt index b61610088..2becac7c1 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 @@ -262,6 +262,7 @@ fun RoutineCompleteScreen( // Polar H10 Session Analytics & 5-minute recovery cooldown PolarSessionAnalyticsCard( + sessionId = polarBleManager?.lastRecordedSessionId?.collectAsState()?.value, modifier = Modifier.fillMaxWidth(), polarBleManager = polarBleManager, workoutRepository = viewModel.workoutRepository, From 053080bc38b0556b6f6549de40f1e6ac69082316 Mon Sep 17 00:00:00 2001 From: StuGotz Date: Mon, 26 Oct 2026 08:24:17 -0500 Subject: [PATCH 17/25] fix(polar): fix Kable initial Disconnected state race, GATT close leak, and workout auto-reconnect --- .../data/ble/BleExtensions.android.kt | 9 ++ .../phoenixproject/data/ble/BleExtensions.kt | 5 + .../data/ble/PolarH10BleManager.kt | 116 ++++++++++++++++- .../data/ble/PolarH10BleManagerTest.kt | 123 ++++++++++++++++++ .../data/ble/BleExtensions.ios.kt | 8 ++ 5 files changed, 258 insertions(+), 3 deletions(-) diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.android.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.android.kt index e8c09787e..e515aff6d 100644 --- a/shared/src/androidMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.android.kt +++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.android.kt @@ -48,3 +48,12 @@ actual suspend fun Peripheral.requestMtuIfSupported(mtu: Int): Int? { null } } + +actual fun Peripheral.closePeripheral() { + try { + Logger.i("BleExtensions") { "🔌 Closing peripheral and releasing GATT resources" } + (this as? AutoCloseable)?.close() + } catch (e: Exception) { + Logger.w("BleExtensions") { "Failed to close peripheral: ${e.message}" } + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.kt index de2e522d4..aaed5fb43 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.kt @@ -16,3 +16,8 @@ expect suspend fun Peripheral.requestHighPriority() * @return The negotiated MTU, or null on iOS/failure */ expect suspend fun Peripheral.requestMtuIfSupported(mtu: Int): Int? + +/** + * Releases and disposes of the peripheral's underlying GATT connection and resources. + */ +expect fun Peripheral.closePeripheral() 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 index f79367502..0dd5a7498 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -20,6 +20,7 @@ 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 @@ -83,6 +84,7 @@ class PolarH10BleManager( private val timeSource: () -> Long = DEFAULT_WALL_CLOCK, monotonicTimeSource: (() -> Long)? = null, private val logRepo: ConnectionLogRepository = ConnectionLogRepository.instance, + internal var autoReconnectRunner: (suspend () -> Result)? = null, ) { private val log = Logger.withTag("PolarH10BleManager") @@ -123,6 +125,8 @@ class PolarH10BleManager( settings?.remove(KEY_POLAR_REMEMBERED_NAME) _rememberedAddress.value = null _rememberedName.value = null + reconnectJob?.cancel() + reconnectJob = null } // ===== Torso Biomechanics Posture & Filtering ===== @@ -252,6 +256,9 @@ class PolarH10BleManager( if (!_isEcgStreaming.value) startEcgStream() if (!_isAccStreaming.value) startAccStream() } + } else if (_rememberedAddress.value != null && _isAutoReconnectEnabled.value) { + log.i { "Workout recording started without active Polar connection; triggering auto-reconnect" } + ensureConnectedOrReconnect() } } @@ -615,8 +622,11 @@ class PolarH10BleManager( 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() } @@ -697,9 +707,21 @@ class PolarH10BleManager( 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 + private var reconnectJob: Job? = null private val discoveredAdvertisements = mutableMapOf() private var stateObserverJob: Job? = null @@ -751,6 +773,8 @@ class PolarH10BleManager( message = "[HRM] Scanning for Polar H10 devices", tag = "HRM", ) + reconnectJob?.cancel() + reconnectJob = null scanJob?.cancel() scanJob = null _scannedDevices.value = emptyList() @@ -825,6 +849,25 @@ class PolarH10BleManager( private class AutoReconnectMatchFound(val device: ScannedDevice) : CancellationException() + /** + * Resets explicit disconnect suppression and triggers an immediate auto-reconnect attempt + * to a remembered Polar sensor. + */ + fun ensureConnectedOrReconnect(): Job { + isExplicitDisconnect = false + reconnectJob?.cancel() + val job = scope.launch { + val runner = autoReconnectRunner + if (runner != null) { + runner.invoke() + } else { + autoReconnectIfRemembered() + } + } + reconnectJob = job + return job + } + /** * Auto-reconnect to the remembered Polar H10 if one was previously connected * and auto-reconnect is enabled. @@ -836,6 +879,10 @@ class PolarH10BleManager( 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)" } @@ -843,7 +890,11 @@ class PolarH10BleManager( val cachedAdv = discoveredAdvertisements[address] ?: discoveredAdvertisements.values.firstOrNull { it.identifier.toString() == address } if (cachedAdv != null) { - return connect(ScannedDevice(name = name, address = address)) + 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 { @@ -870,6 +921,7 @@ class PolarH10BleManager( 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) { @@ -884,15 +936,32 @@ class PolarH10BleManager( */ suspend fun connect(device: ScannedDevice): Result { log.i { "Connecting to Polar H10: ${device.name} (${device.address})" } + reconnectJob?.cancel() + reconnectJob = null + + val wasScanning = scanJob?.isActive == true || _connectionState.value is ConnectionState.Scanning stopScanning() + if (wasScanning) { + delay(150) + } val advertisement = discoveredAdvertisements[device.address] ?: discoveredAdvertisements.values.firstOrNull { it.identifier.toString() == 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 = Peripheral(advertisement) peripheral = p @@ -910,6 +979,7 @@ class PolarH10BleManager( _connectionState.value = ConnectionState.Connecting } is State.Connected -> { + wasEverConnected = true _isConnected.value = true _connectionState.value = ConnectionState.Connected( deviceName = device.name, @@ -934,6 +1004,11 @@ class PolarH10BleManager( _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( @@ -1703,6 +1778,10 @@ class PolarH10BleManager( */ suspend fun disconnect() { _isConnected.value = false + isExplicitDisconnect = true + reconnectJob?.cancel() + reconnectJob = null + val p = peripheral if (p != null) { log.i { "Disconnecting Polar H10" } @@ -1723,6 +1802,12 @@ class PolarH10BleManager( e.rethrowIfCancellation() log.w { "Error during Polar disconnect: ${e.message}" } } + try { + p.closePeripheral() + } catch (e: Exception) { + e.rethrowIfCancellation() + log.w { "Error during Polar close: ${e.message}" } + } } cleanupExistingConnection() _connectionState.value = ConnectionState.Disconnected @@ -1776,20 +1861,43 @@ class PolarH10BleManager( _dynamicAccMg.value = null } - private fun cleanupExistingConnection() { + 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.closePeripheral() + } 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 { @@ -1801,9 +1909,11 @@ class PolarH10BleManager( } p.disconnect() } catch (_: Exception) {} + try { + p.closePeripheral() + } catch (_: Exception) {} } } - cleanupExistingConnection() } internal fun isPolarH10Advertisement(adv: Advertisement): Boolean { 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 index 10ed12d85..92b1a8faa 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt @@ -9,6 +9,7 @@ import com.devil.phoenixproject.util.BleConstants import com.juul.kable.Advertisement import com.juul.kable.Identifier import com.juul.kable.ManufacturerData +import com.russhwolf.settings.MapSettings import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -21,6 +22,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy @@ -793,6 +795,127 @@ class PolarH10BleManagerTest { 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 resets isExplicitDisconnect and auto-reconnects to remembered sensor`() { + val settings = MapSettings() + settings.putString(PolarH10BleManager.KEY_POLAR_REMEMBERED_ADDRESS, "24:AC:AC:0E:5B:59") + settings.putString(PolarH10BleManager.KEY_POLAR_REMEMBERED_NAME, "Polar H10 12345678") + + val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val manager = PolarH10BleManager( + settings = settings, + scope = testScope, + autoReconnectRunner = { + delay(Long.MAX_VALUE) + Result.success(Unit) + }, + ) + + runTest { + manager.disconnect() + } + assertTrue(manager.isExplicitDisconnectForTesting) + + // Starting a workout should clear the explicit disconnect suppression and trigger auto-reconnect + manager.startSessionRecording("session-123", "WORKING_SET") + + assertFalse(manager.isExplicitDisconnectForTesting) + assertTrue(manager.isReconnectJobActiveForTesting) + + 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, + autoReconnectRunner = { + delay(Long.MAX_VALUE) + Result.success(Unit) + }, + ) + + 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() + } } diff --git a/shared/src/iosMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.ios.kt b/shared/src/iosMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.ios.kt index a0e441595..ca08afb1b 100644 --- a/shared/src/iosMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.ios.kt +++ b/shared/src/iosMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.ios.kt @@ -11,3 +11,11 @@ actual suspend fun Peripheral.requestMtuIfSupported(mtu: Int): Int? { // No explicit API to request MTU - returns null to indicate "use system default" return null } + +actual fun Peripheral.closePeripheral() { + try { + (this as? AutoCloseable)?.close() + } catch (_: Exception) { + // Ignored on iOS + } +} From 728f957bd75fe7649406ffa895d8704d09e4a542 Mon Sep 17 00:00:00 2001 From: StuGotz Date: Wed, 28 Oct 2026 13:36:18 -0500 Subject: [PATCH 18/25] fix(polar): resolve phoneMain rebase, fix auto-reconnect & chart loading, drop closePeripheral --- docs/qa/persisted-file-upgrade.md | 2 +- docs/qa/profile-schema42-fixture.md | 6 +- .../qa/Invoke-PhoenixPersistedFileUpgrade.ps1 | 2 +- .../data/ble/BleExtensions.android.kt | 9 -- .../phoenixproject/data/ble/BleExtensions.kt | 5 - .../data/ble/PolarH10BleManager.kt | 96 +++++++++---------- .../data/local/SchemaManifest.kt | 4 +- .../manager/ActiveSessionEngine.kt | 2 +- .../util/PolarTelemetryUtils.kt | 10 -- .../database/PhoenixDatabase.sq | 16 +--- .../phoenixproject/database/migrations/58.sqm | 1 - .../data/ble/PolarH10BleManagerTest.kt | 49 +++++++--- .../util/PolarRollingTelemetryUtilsTest.kt | 2 +- .../data/ble/BleExtensions.ios.kt | 8 -- .../components/PolarH10SettingsCard.kt | 0 .../components/PolarH10WorkoutStrip.kt | 2 +- .../components/PolarSessionAnalyticsCard.kt | 7 +- .../screen/ActiveWorkoutScreen.kt | 3 +- .../presentation/screen/AnalyticsScreen.kt | 1 + .../presentation/screen/HistoryTab.kt | 8 ++ .../presentation/screen/SetSummaryCard.kt | 3 + .../presentation/screen/WorkoutTab.kt | 6 ++ 22 files changed, 119 insertions(+), 123 deletions(-) rename shared/src/{commonMain => phoneMain}/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt (100%) rename shared/src/{commonMain => phoneMain}/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt (99%) rename shared/src/{commonMain => phoneMain}/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt (99%) diff --git a/docs/qa/persisted-file-upgrade.md b/docs/qa/persisted-file-upgrade.md index 842056825..72e45d752 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 60, the current database `user_version` (highest migration file + 1). Pass that parameter only to override it. +The harness default `-ExpectedSchemaVersion` is 59, 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/profile-schema42-fixture.md b/docs/qa/profile-schema42-fixture.md index ecac82adb..e71695661 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 60 (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 59 (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 '60') { throw "Expected user_version 60 after upgrade, got $userVersion" } +if ($userVersion -ne '59') { throw "Expected user_version 59 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` 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. +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. diff --git a/scripts/qa/Invoke-PhoenixPersistedFileUpgrade.ps1 b/scripts/qa/Invoke-PhoenixPersistedFileUpgrade.ps1 index a9247a1e8..e903a146c 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 = 60 + [int] $ExpectedSchemaVersion = 59 ) Set-StrictMode -Version Latest diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.android.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.android.kt index e515aff6d..e8c09787e 100644 --- a/shared/src/androidMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.android.kt +++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.android.kt @@ -48,12 +48,3 @@ actual suspend fun Peripheral.requestMtuIfSupported(mtu: Int): Int? { null } } - -actual fun Peripheral.closePeripheral() { - try { - Logger.i("BleExtensions") { "🔌 Closing peripheral and releasing GATT resources" } - (this as? AutoCloseable)?.close() - } catch (e: Exception) { - Logger.w("BleExtensions") { "Failed to close peripheral: ${e.message}" } - } -} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.kt index aaed5fb43..de2e522d4 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.kt @@ -16,8 +16,3 @@ expect suspend fun Peripheral.requestHighPriority() * @return The negotiated MTU, or null on iOS/failure */ expect suspend fun Peripheral.requestMtuIfSupported(mtu: Int): Int? - -/** - * Releases and disposes of the peripheral's underlying GATT connection and resources. - */ -expect fun Peripheral.closePeripheral() 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 index 0dd5a7498..08a5b0728 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -32,7 +32,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException 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 @@ -84,7 +86,8 @@ class PolarH10BleManager( private val timeSource: () -> Long = DEFAULT_WALL_CLOCK, monotonicTimeSource: (() -> Long)? = null, private val logRepo: ConnectionLogRepository = ConnectionLogRepository.instance, - internal var autoReconnectRunner: (suspend () -> Result)? = null, + private val advertisements: () -> Flow = { Scanner {}.advertisements }, + private val createPeripheral: (Advertisement) -> Peripheral = { Peripheral(it) }, ) { private val log = Logger.withTag("PolarH10BleManager") @@ -152,7 +155,6 @@ class PolarH10BleManager( // ===== Session Recording & Recovery State ===== internal data class BufferedBeatSample( - val sessionId: String, val timestampMs: Long, val phase: String, val hrBpm: Long, @@ -164,7 +166,6 @@ class PolarH10BleManager( ) internal data class BufferedEcgSegment( - val sessionId: String, val sensorTimestampNs: Long, val wallClockTimestampMs: Long, val phase: String, @@ -181,7 +182,6 @@ class PolarH10BleManager( private val recentRrs = ArrayDeque() private val bufferedBeatSamples = mutableListOf() private val bufferedEcgSegments = mutableListOf() - private val committedSessionIds = mutableSetOf() private var maxTorsoPitch: Double? = null private var maxTorsoRoll: Double? = null private var endHeartRate: Int? = null @@ -238,7 +238,6 @@ class PolarH10BleManager( sessionLock.withLock { bufferedBeatSamples.clear() bufferedEcgSegments.clear() - committedSessionIds.remove(sessionId) sessionHrs.clear() sessionRrs.clear() recentRrs.clear() @@ -266,12 +265,8 @@ class PolarH10BleManager( * Flush buffered telemetry records to the database within a transaction once the parent * [WorkoutSession] row is committed. Subsequent records will insert directly into SQLite. */ - fun flushSessionTelemetry(sessionId: String, fallbackSessionId: String? = null) { + fun flushSessionTelemetry(sessionId: String) { val (beatsToFlush, ecgsToFlush) = sessionLock.withLock { - committedSessionIds.add(sessionId) - if (fallbackSessionId != null) { - committedSessionIds.add(fallbackSessionId) - } activeSessionId = sessionId recordedSessionId = sessionId _lastRecordedSessionId.value = sessionId @@ -329,8 +324,8 @@ class PolarH10BleManager( maxTorsoRoll = null } val targetSessionId = activeSessionId - if (targetSessionId != null) { - committedSessionIds.remove(targetSessionId) + if (targetSessionId != null && _lastRecordedSessionId.value == targetSessionId) { + _lastRecordedSessionId.value = null } lastBeatMonotonicMs = null activeSessionId = null @@ -439,7 +434,6 @@ class PolarH10BleManager( activeSessionId = null _activeCooldownSessionId.value = null recordedSessionId = null - _lastRecordedSessionId.value = null scope.launch { stopEcgStream() stopAccStream() @@ -549,7 +543,6 @@ class PolarH10BleManager( activeSessionId = null _activeCooldownSessionId.value = null recordedSessionId = null - _lastRecordedSessionId.value = null stopEcgStream() stopAccStream() } @@ -787,8 +780,7 @@ class PolarH10BleManager( scanJob = scope.launch { try { withTimeoutOrNull(BleConstants.SCAN_TIMEOUT_MS) { - Scanner {} - .advertisements + advertisements() .filter { adv -> isPolarH10Advertisement(adv) } .collect { adv -> val identifier = adv.identifier.toString() @@ -851,21 +843,19 @@ class PolarH10BleManager( /** * Resets explicit disconnect suppression and triggers an immediate auto-reconnect attempt - * to a remembered Polar sensor. + * to a remembered Polar sensor if not already connecting or reconnecting. */ - fun ensureConnectedOrReconnect(): Job { + fun ensureConnectedOrReconnect() { isExplicitDisconnect = false - reconnectJob?.cancel() - val job = scope.launch { - val runner = autoReconnectRunner - if (runner != null) { - runner.invoke() - } else { - autoReconnectIfRemembered() - } + if (_connectionState.value is ConnectionState.Connected || _connectionState.value is ConnectionState.Connecting) { + return + } + if (reconnectJob?.isActive == true) { + return + } + reconnectJob = scope.launch { + autoReconnectIfRemembered() } - reconnectJob = job - return job } /** @@ -901,8 +891,7 @@ class PolarH10BleManager( val matchedDevice = withTimeoutOrNull(timeoutMs) { var found: ScannedDevice? = null try { - Scanner {} - .advertisements + advertisements() .filter { isPolarH10Advertisement(it) } .collect { adv -> val id = adv.identifier.toString() @@ -936,8 +925,11 @@ class PolarH10BleManager( */ suspend fun connect(device: ScannedDevice): Result { log.i { "Connecting to Polar H10: ${device.name} (${device.address})" } - reconnectJob?.cancel() - reconnectJob = null + val currentJob = currentCoroutineContext()[Job] + if (reconnectJob !== currentJob) { + reconnectJob?.cancel() + reconnectJob = null + } val wasScanning = scanJob?.isActive == true || _connectionState.value is ConnectionState.Scanning stopScanning() @@ -963,7 +955,7 @@ class PolarH10BleManager( isExplicitDisconnect = false wasEverConnected = false - val p = Peripheral(advertisement) + val p = createPeripheral(advertisement) peripheral = p stateObserverJob = scope.launch { @@ -1136,15 +1128,15 @@ class PolarH10BleManager( 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." } - if (!measurement.contactDetected) { - logRepo.warning( - eventType = LogEventType.DIAGNOSTIC, - message = "[HRM] Poor skin contact detected (contact=${measurement.contactDetected}, hr=${measurement.hr})", - tag = "HRM", - ) - } if (previousContactDetected == true) { previousContactDetected = false + if (!measurement.contactDetected) { + logRepo.warning( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] Poor skin contact detected (contact=${measurement.contactDetected}, hr=${measurement.hr})", + tag = "HRM", + ) + } logRepo.debug( eventType = LogEventType.DIAGNOSTIC, message = "[HRM] Contact lost (contact: true -> false)", @@ -1194,15 +1186,15 @@ class PolarH10BleManager( return } if (!measurement.contactDetected || measurement.hr <= 0 || isBatteryCritical()) { - if (!measurement.contactDetected) { - logRepo.warning( - eventType = LogEventType.DIAGNOSTIC, - message = "[HRM] Poor skin contact detected (contact=${measurement.contactDetected}, hr=${measurement.hr})", - tag = "HRM", - ) - } if (previousContactDetected == true) { previousContactDetected = false + if (!measurement.contactDetected) { + logRepo.warning( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] Poor skin contact detected (contact=${measurement.contactDetected}, hr=${measurement.hr})", + tag = "HRM", + ) + } logRepo.debug( eventType = LogEventType.DIAGNOSTIC, message = "[HRM] Contact lost (contact: true -> false)", @@ -1275,7 +1267,6 @@ class PolarH10BleManager( sessionLock.withLock { bufferedBeatSamples.add( BufferedBeatSample( - sessionId = currentSid, timestampMs = beat.timestampMs, phase = currentPhase, hrBpm = beat.hr.toLong(), @@ -1400,7 +1391,6 @@ class PolarH10BleManager( sessionLock.withLock { bufferedEcgSegments.add( BufferedEcgSegment( - sessionId = currentSid, sensorTimestampNs = measurement.sensorTimestampNs, wallClockTimestampMs = nowMs, phase = currentPhase, @@ -1797,13 +1787,15 @@ class PolarH10BleManager( if (_isAccStreaming.value) { try { stopAccStream() } catch (_: Exception) {} } + peripheral = null p.disconnect() } catch (e: Exception) { + peripheral = null e.rethrowIfCancellation() log.w { "Error during Polar disconnect: ${e.message}" } } try { - p.closePeripheral() + p.close() } catch (e: Exception) { e.rethrowIfCancellation() log.w { "Error during Polar close: ${e.message}" } @@ -1875,7 +1867,7 @@ class PolarH10BleManager( log.w { "Cleanup disconnect error (non-fatal): ${e.message}" } } try { - p.closePeripheral() + p.close() } catch (e: Exception) { e.rethrowIfCancellation() log.w { "Cleanup close error (non-fatal): ${e.message}" } @@ -1910,7 +1902,7 @@ class PolarH10BleManager( p.disconnect() } catch (_: Exception) {} try { - p.closePeripheral() + p.close() } catch (_: Exception) {} } } 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 35b01ae54..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 @@ -1772,7 +1772,7 @@ 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 58: Polar H10 cardiac & torso biomechanics summary + // 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"), @@ -1784,7 +1784,7 @@ internal val manifestColumns: List = listOf( SchemaHealOperation("WorkoutSession", "maxTorsoRollDeg", "ALTER TABLE WorkoutSession ADD COLUMN maxTorsoRollDeg REAL"), // ── MetricSample (8 columns) ───────────────────────────────────────── - // Migration 58: Polar H10 intra-set synchronized telemetry & torso biomechanics + // 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"), 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 5c460dc3a..19d8e0249 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 @@ -10860,7 +10860,7 @@ class ActiveSessionEngine( repMetrics = emptyList(), repBiomechanics = emptyList(), ) - polarBleManager?.flushSessionTelemetry(session.id, fallbackSessionId = coordinator.currentSessionId) + polarBleManager?.flushSessionTelemetry(session.id) val completedSetId = completedSet?.id if (completedSet != null) { Logger.d( diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt index 16c1e5ea5..7b16e775d 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/PolarTelemetryUtils.kt @@ -141,16 +141,6 @@ object PolarTelemetryUtils { fun formatMs(ms: Double?): String = ms?.let { "${it.roundToInt()} ms" } ?: "—" } -/** - * Computes rolling RMSSD (Root Mean Square of Successive Differences) in milliseconds - * over a sliding time window (default 60 seconds). - */ -fun computeRollingRmssd( - beats: List, - windowMs: Long = 60_000L, - currentTimeMs: Long, -): Double? = PolarTelemetryUtils.computeRollingRmssd(beats, windowMs, currentTimeMs) - /** * Stateful exponential moving average (EMA) low-pass filter for 50 Hz Polar ACC stream. */ 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 3b5d81fdc..6a4b7123c 100644 --- a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/PhoenixDatabase.sq +++ b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/PhoenixDatabase.sq @@ -123,7 +123,7 @@ CREATE TABLE WorkoutSession ( 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), - -- Polar H10 cardiac & torso biomechanics summary (added in migration 58) + -- Polar H10 cardiac & torso biomechanics summary (added in migration 59) avgHeartRateBpm INTEGER, maxHeartRateBpm INTEGER, minHeartRateBpm INTEGER, @@ -161,7 +161,7 @@ CREATE TABLE MetricSample ( loadB REAL, power REAL, status INTEGER NOT NULL DEFAULT 0, - -- Polar H10 intra-set synchronized telemetry & torso biomechanics (added in migration 58) + -- Polar H10 intra-set synchronized telemetry & torso biomechanics (added in migration 59) heartRateBpm INTEGER, rrIntervalMs REAL, torsoPitchDeg REAL, @@ -175,7 +175,7 @@ CREATE TABLE MetricSample ( CREATE INDEX idx_metric_sample_session ON MetricSample(sessionId); --- Continuous beat & recovery timeline (added in migration 58) +-- Continuous beat & recovery timeline (added in migration 59) CREATE TABLE PolarBeatSample ( id INTEGER PRIMARY KEY AUTOINCREMENT, sessionId TEXT NOT NULL, @@ -192,7 +192,7 @@ CREATE TABLE PolarBeatSample ( CREATE INDEX idx_polar_beat_sample_session_ts ON PolarBeatSample(sessionId, timestampMs); --- 130 Hz raw ECG trace storage (added in migration 58) +-- 130 Hz raw ECG trace storage (added in migration 59) CREATE TABLE PolarEcgSegment ( id INTEGER PRIMARY KEY AUTOINCREMENT, sessionId TEXT NOT NULL, @@ -1233,7 +1233,7 @@ VALUES ( deleteMetricsBySession: DELETE FROM MetricSample WHERE sessionId = ?; --- Polar Queries (added in migration 58) +-- Polar Queries (added in migration 59) updateSessionPolarSummary: UPDATE WorkoutSession SET avgHeartRateBpm = :avgHeartRateBpm, @@ -1262,9 +1262,6 @@ DELETE FROM PolarBeatSample WHERE sessionId = ?; countBackupPolarBeatSamples: SELECT COUNT(*) FROM PolarBeatSample WHERE sessionId IN (SELECT id FROM WorkoutSession WHERE deletedAt IS NULL); -selectAllBackupPolarBeatSamples: -SELECT * FROM PolarBeatSample WHERE sessionId IN (SELECT id FROM WorkoutSession WHERE deletedAt IS NULL) ORDER BY timestampMs ASC; - insertPolarBeatSampleRestoreIfAbsent: INSERT OR IGNORE INTO PolarBeatSample ( id, sessionId, timestampMs, phase, hrBpm, rrMs, rmssdMs, @@ -1288,9 +1285,6 @@ DELETE FROM PolarEcgSegment WHERE sessionId = ?; countBackupPolarEcgSegments: SELECT COUNT(*) FROM PolarEcgSegment WHERE sessionId IN (SELECT id FROM WorkoutSession WHERE deletedAt IS NULL); -selectAllBackupPolarEcgSegments: -SELECT * FROM PolarEcgSegment WHERE sessionId IN (SELECT id FROM WorkoutSession WHERE deletedAt IS NULL) ORDER BY wallClockTimestampMs ASC; - insertPolarEcgSegmentRestoreIfAbsent: INSERT OR IGNORE INTO PolarEcgSegment ( id, sessionId, sensorTimestampNs, wallClockTimestampMs, phase, samplesUvCsv diff --git a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/58.sqm b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/58.sqm index 3d72fd511..4d2a24f1a 100644 --- a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/58.sqm +++ b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/58.sqm @@ -14,4 +14,3 @@ ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_local_generation ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_server_revision INTEGER NOT NULL DEFAULT 0; ALTER TABLE UserProfilePreferences ADD COLUMN custom_equipment_dirty INTEGER NOT NULL DEFAULT 0; - 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 index 92b1a8faa..8957e6a5d 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt @@ -21,8 +21,11 @@ import kotlin.uuid.Uuid import kotlinx.coroutines.CoroutineScope 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.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy @@ -32,12 +35,15 @@ import kotlinx.coroutines.test.runTest class PolarH10BleManagerTest { private class FakeAdvertisement( + id: String = "00000000-0000-0000-0000-001122334455", override val name: String? = null, override val uuids: List = emptyList(), override val rssi: Int = -60, ) : Advertisement { + private val uuid: Any = Uuid.parse(id) + @Suppress("USELESS_CAST") + override val identifier: Identifier = if (uuid is Identifier) uuid else id as Identifier override val peripheralName: String? get() = name - override val identifier: Identifier get() = "00:11:22:33:44:55" override val isConnectable: Boolean get() = true override val txPower: Int? get() = null override val manufacturerData: ManufacturerData? get() = null @@ -689,14 +695,23 @@ class PolarH10BleManagerTest { assertTrue(manager.isCooldownActive.value) assertEquals("session-A", manager.activeCooldownSessionId.value) - // Ending cooldown clears state + // 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, further queries reject + // Once cooldown ended, summary queries reject, arbitrary sessions reject cooldown, + // but the just-finished session-A can explicitly restart cooldown via forceRestart assertNull(manager.getSessionSummary("session-A")) + 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() } @@ -826,18 +841,22 @@ class PolarH10BleManagerTest { @Test fun `startSessionRecording resets isExplicitDisconnect and auto-reconnects to remembered sensor`() { + val fakeAd = FakeAdvertisement( + 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, "24:AC:AC:0E:5B:59") + 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, - autoReconnectRunner = { - delay(Long.MAX_VALUE) - Result.success(Unit) - }, + advertisements = { flowOf(fakeAd) }, + createPeripheral = { ad -> CompositionGattFake(ad, Dispatchers.Unconfined) }, ) runTest { @@ -845,11 +864,16 @@ class PolarH10BleManagerTest { } assertTrue(manager.isExplicitDisconnectForTesting) - // Starting a workout should clear the explicit disconnect suppression and trigger auto-reconnect + // 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") assertFalse(manager.isExplicitDisconnectForTesting) - assertTrue(manager.isReconnectJobActiveForTesting) + assertTrue(manager.isConnected.value) + assertEquals( + ConnectionState.Connected("Polar H10 12345678", rememberedId), + manager.connectionState.value, + ) testScope.cancel() } @@ -863,10 +887,7 @@ class PolarH10BleManagerTest { val manager = PolarH10BleManager( settings = settings, scope = testScope, - autoReconnectRunner = { - delay(Long.MAX_VALUE) - Result.success(Unit) - }, + advertisements = { flow { awaitCancellation() } }, ) manager.ensureConnectedOrReconnect() 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 index 49618bf98..5abccf1f4 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarRollingTelemetryUtilsTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/util/PolarRollingTelemetryUtilsTest.kt @@ -2,7 +2,7 @@ package com.devil.phoenixproject.presentation.util import com.devil.phoenixproject.domain.model.PolarRrBeat import com.devil.phoenixproject.util.PolarTelemetryUtils -import com.devil.phoenixproject.util.computeRollingRmssd +import com.devil.phoenixproject.util.PolarTelemetryUtils.computeRollingRmssd import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull diff --git a/shared/src/iosMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.ios.kt b/shared/src/iosMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.ios.kt index ca08afb1b..a0e441595 100644 --- a/shared/src/iosMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.ios.kt +++ b/shared/src/iosMain/kotlin/com/devil/phoenixproject/data/ble/BleExtensions.ios.kt @@ -11,11 +11,3 @@ actual suspend fun Peripheral.requestMtuIfSupported(mtu: Int): Int? { // No explicit API to request MTU - returns null to indicate "use system default" return null } - -actual fun Peripheral.closePeripheral() { - try { - (this as? AutoCloseable)?.close() - } catch (_: Exception) { - // Ignored on iOS - } -} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt similarity index 100% rename from shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt rename to shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt similarity index 99% rename from shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt rename to shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt index c1a80d3fb..87702072c 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt @@ -57,7 +57,7 @@ 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.computeRollingRmssd +import com.devil.phoenixproject.util.PolarTelemetryUtils.computeRollingRmssd import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlin.math.roundToInt diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt similarity index 99% rename from shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt rename to shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt index b0ac9e588..ad3d07a7a 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt @@ -45,6 +45,7 @@ import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.ui.theme.Spacing import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.koin.mp.KoinPlatformTools import kotlin.math.abs import kotlin.math.roundToInt @@ -66,7 +67,9 @@ fun PolarSessionAnalyticsCard( showCooldown: Boolean = true, modifier: Modifier = Modifier, polarBleManager: PolarH10BleManager? = null, - workoutRepository: WorkoutRepository? = null, + workoutRepository: WorkoutRepository? = remember { + KoinPlatformTools.defaultContext().getOrNull()?.getOrNull() + }, ) { val connectionState = polarBleManager?.connectionState?.collectAsState()?.value ?: ConnectionState.Disconnected val isCooldownActive = polarBleManager?.isCooldownActive?.collectAsState()?.value ?: false @@ -578,7 +581,7 @@ fun PolarSessionAnalyticsCard( ?: polarBleManager?.lastRecordedSessionId?.value ?: polarBleManager?.latestSessionSummary?.value?.sessionId if (targetId != null) { - polarBleManager?.triggerCooldown(targetId) + polarBleManager?.triggerCooldown(targetId, forceRestart = true) } }, shape = MaterialTheme.shapes.small, 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 1537d176a..4e340d8e4 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 @@ -199,7 +199,7 @@ fun ActiveWorkoutScreen( } LaunchedEffect(Unit) { - polarBleManager.autoReconnectIfRemembered() + polarBleManager.ensureConnectedOrReconnect() } LaunchedEffect(Unit) { @@ -562,6 +562,7 @@ fun ActiveWorkoutScreen( 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 b2ffc6487..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 @@ -573,6 +573,7 @@ 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/HistoryTab.kt b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/screen/HistoryTab.kt index 16ee992bf..ffd0a490a 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 @@ -57,6 +57,7 @@ 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 @@ -135,6 +136,7 @@ fun HistoryTab( // 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). @@ -232,6 +234,7 @@ fun HistoryTab( recentJustLiftExerciseIds = recentJustLiftExerciseIds, customEquipmentOverlay = customEquipmentOverlay, polarBleManager = polarBleManager, + workoutRepository = workoutRepository, onDelete = { onDeleteWorkout(item.session.id) }, ) } @@ -249,6 +252,7 @@ fun HistoryTab( 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 @@ -280,6 +284,7 @@ private fun WorkoutHistoryCard( // 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() @@ -574,6 +579,7 @@ private fun WorkoutHistoryCard( sessionId = session.id, session = session, polarBleManager = polarBleManager, + workoutRepository = workoutRepository, showCooldown = false, ) } @@ -842,6 +848,7 @@ private fun GroupedRoutineCard( // 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`). @@ -1205,6 +1212,7 @@ private fun GroupedRoutineCard( sessionId = polarSession.id, session = polarSession, polarBleManager = polarBleManager, + workoutRepository = workoutRepository, showCooldown = false, ) } 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 40b2e29c9..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 @@ -45,6 +45,7 @@ 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 @@ -111,6 +112,7 @@ fun SetSummaryCard( // 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) } @@ -437,6 +439,7 @@ fun SetSummaryCard( PolarSessionAnalyticsCard( sessionId = summary.sessionId, polarBleManager = polarBleManager, + workoutRepository = workoutRepository, showCooldown = false, modifier = Modifier.padding(bottom = 12.dp), ) 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 5a3d4893a..815a803af 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 @@ -45,6 +45,7 @@ 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 @@ -145,6 +146,7 @@ fun WorkoutTab( // 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 @@ -301,6 +303,7 @@ fun WorkoutTab( onResetForNewWorkout = onResetForNewWorkout, machineTeardownState = machineTeardownState, polarBleManager = polarBleManager, + workoutRepository = workoutRepository, ) } @@ -428,6 +431,7 @@ fun WorkoutTab( 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 @@ -947,6 +951,7 @@ private fun CompletedCard( 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). @@ -1040,6 +1045,7 @@ private fun CompletedCard( PolarSessionAnalyticsCard( modifier = Modifier.padding(vertical = Spacing.small), polarBleManager = polarBleManager, + workoutRepository = workoutRepository, ) if (hasMoreExercises && nextExercise != null) { From 2c722a2a539e94820f4dddf1a6140ab8a937f572 Mon Sep 17 00:00:00 2001 From: StuGotz Date: Wed, 28 Oct 2026 14:09:36 -0500 Subject: [PATCH 19/25] fix(polar): preserve lastRecordedSessionId across routine Idle transition and fix Start Cooldown on RoutineCompleteScreen --- .../data/ble/PolarH10BleManager.kt | 37 ++++-- .../manager/DefaultWorkoutSessionManager.kt | 11 +- .../data/ble/PolarH10BleManagerTest.kt | 124 ++++++++++++++++++ .../components/PolarSessionAnalyticsCard.kt | 33 +++-- .../screen/RoutineCompleteScreen.kt | 12 +- 5 files changed, 193 insertions(+), 24 deletions(-) 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 index 08a5b0728..1e0f20817 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -215,6 +215,7 @@ class PolarH10BleManager( val latestSessionSummary: StateFlow = _latestSessionSummary.asStateFlow() private val completedCooldownSessionIds = mutableSetOf() + private val committedSessionIds = mutableSetOf() private var completedCooldownSessionId: String? = null val currentRecordingSessionId: String? get() = activeSessionId @@ -230,6 +231,7 @@ class PolarH10BleManager( _cooldownStartHeartRate.value = null _activeCooldownSessionId.value = null completedCooldownSessionIds.remove(sessionId) + committedSessionIds.remove(sessionId) completedCooldownSessionId = null activeSessionId = sessionId recordedSessionId = sessionId @@ -267,7 +269,12 @@ class PolarH10BleManager( */ fun flushSessionTelemetry(sessionId: String) { val (beatsToFlush, ecgsToFlush) = sessionLock.withLock { - activeSessionId = sessionId + committedSessionIds.add(sessionId) + activeSessionId = if (_isCooldownActive.value && _activeCooldownSessionId.value == sessionId) { + sessionId + } else { + null + } recordedSessionId = sessionId _lastRecordedSessionId.value = sessionId val beats = bufferedBeatSamples.toList() @@ -276,6 +283,7 @@ class PolarH10BleManager( bufferedEcgSegments.clear() Pair(beats, ecgs) } + _latestSessionSummary.value = getSessionSummary(sessionId) if (database != null && (beatsToFlush.isNotEmpty() || ecgsToFlush.isNotEmpty())) { try { @@ -312,8 +320,12 @@ class PolarH10BleManager( /** * 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() { + val targetSessionId = activeSessionId ?: return + if (_isCooldownActive.value || committedSessionIds.contains(targetSessionId)) return sessionLock.withLock { bufferedBeatSamples.clear() bufferedEcgSegments.clear() @@ -323,8 +335,7 @@ class PolarH10BleManager( maxTorsoPitch = null maxTorsoRoll = null } - val targetSessionId = activeSessionId - if (targetSessionId != null && _lastRecordedSessionId.value == targetSessionId) { + if (_lastRecordedSessionId.value == targetSessionId) { _lastRecordedSessionId.value = null } lastBeatMonotonicMs = null @@ -384,12 +395,17 @@ class PolarH10BleManager( fun triggerCooldown(sessionId: String? = null, forceRestart: Boolean = false) { val knownRecordedId = recordedSessionId ?: _lastRecordedSessionId.value - val targetId: String = sessionId + val candidateId: String = sessionId ?: knownRecordedId ?: (if (forceRestart) completedCooldownSessionId else null) ?: return - if (targetId != knownRecordedId && (!forceRestart || targetId != completedCooldownSessionId)) return - if (forceRestart && targetId == completedCooldownSessionId) { + val targetId: String = when { + candidateId == knownRecordedId -> candidateId + forceRestart && candidateId == completedCooldownSessionId -> candidateId + committedSessionIds.contains(candidateId) -> knownRecordedId ?: candidateId + else -> return + } + if (forceRestart) { completedCooldownSessionIds.remove(targetId) } recordedSessionId = targetId @@ -442,11 +458,16 @@ class PolarH10BleManager( fun endSessionRecording(sessionId: String? = null, forceRestart: Boolean = false) { val knownRecordedId = recordedSessionId ?: _lastRecordedSessionId.value - val targetSessionId: String = sessionId + val candidateId: String = sessionId ?: knownRecordedId ?: (if (forceRestart) completedCooldownSessionId else null) ?: return - if (targetSessionId != knownRecordedId && (!forceRestart || targetSessionId != completedCooldownSessionId)) return + val targetSessionId: String = when { + candidateId == knownRecordedId -> candidateId + forceRestart && candidateId == completedCooldownSessionId -> candidateId + committedSessionIds.contains(candidateId) -> knownRecordedId ?: candidateId + else -> return + } if (forceRestart && targetSessionId == completedCooldownSessionId) { completedCooldownSessionIds.remove(targetSessionId) } 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 08a67287b..7be4bbf6d 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 @@ -533,14 +533,17 @@ class DefaultWorkoutSessionManager( } is WorkoutState.Completed, WorkoutState.RoutineComplete -> { if (polarBleManager.isConnected.value) { - polarBleManager.endSessionRecording(coordinator.currentSessionId) + polarBleManager.endSessionRecording( + coordinator.currentSessionId ?: polarBleManager.lastRecordedSessionId.value, + ) } } is WorkoutState.SetSummary -> { polarBleManager.updateSessionPhase("REST") } is WorkoutState.Idle -> { - if (!polarBleManager.isCooldownActive.value && + if (polarBleManager.currentRecordingSessionId != null && + !polarBleManager.isCooldownActive.value && coordinator.routineFlowState.value !is RoutineFlowState.Complete) { polarBleManager.discardActiveRecording() } @@ -559,7 +562,9 @@ class DefaultWorkoutSessionManager( coordinator.routineFlowState.collect { flowState -> if (flowState is RoutineFlowState.Complete) { if (polarBleManager.isConnected.value) { - polarBleManager.endSessionRecording(coordinator.currentSessionId) + polarBleManager.endSessionRecording( + coordinator.currentSessionId ?: polarBleManager.lastRecordedSessionId.value, + ) } } } 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 index 8957e6a5d..8bb5353e2 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt @@ -937,6 +937,130 @@ class PolarH10BleManagerTest { 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( + database = database, + scope = this, + timeSource = { simulatedTime }, + ) + manager.setConnectedForTesting(true) + + val set1Id = "ea838be2-3983-4ec3-8eb4-c8a75fdd52a2" + val set2Id = "9615342a-142c-4cd7-838e-5cf38ab9b7f7" + + fun insertSessionRow(id: String, ts: Long) { + database.phoenixDatabaseQueries.insertSession( + id = id, + timestamp = ts, + mode = "OldSchool", + targetReps = 10L, + weightPerCableKg = 20.0, + progressionKg = 0.0, + duration = 30_000L, + totalReps = 10L, + warmupReps = 0L, + workingReps = 10L, + isJustLift = 0L, + stopAtTop = 0L, + eccentricLoad = 100L, + echoLevel = 1L, + exerciseId = null, + exerciseName = "Bench Press", + routineSessionId = "c1b268f6-6402-4a07-b50d-48ed76a023bc", + routineName = "Test Routine", + 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 = "[]", + ) + } + + // 1. Set 1 recording & flush + manager.startSessionRecording(set1Id, "WORKING_SET") + manager.processRrIntervals(PolarHrMeasurement(hr = 148, contactDetected = true, rrIntervalsMs = listOf(800.0))) + insertSessionRow(set1Id, 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))) + insertSessionRow(set2Id, 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) + + // 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) + } } 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 index ad3d07a7a..cc3dd042e 100644 --- a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt @@ -82,12 +82,21 @@ fun PolarSessionAnalyticsCard( val activeCooldownSessionId = polarBleManager?.activeCooldownSessionId?.collectAsState()?.value val lastRecordedSessionId = polarBleManager?.lastRecordedSessionId?.collectAsState()?.value - val resolvedSessionId = sessionId - ?: session?.id - ?: activeCooldownSessionId - ?: polarBleManager?.currentRecordingSessionId - ?: latestSummary?.sessionId - ?: lastRecordedSessionId + val resolvedSessionId = if (showCooldown) { + activeCooldownSessionId + ?: lastRecordedSessionId + ?: sessionId + ?: session?.id + ?: polarBleManager?.currentRecordingSessionId + ?: latestSummary?.sessionId + } else { + sessionId + ?: session?.id + ?: activeCooldownSessionId + ?: polarBleManager?.currentRecordingSessionId + ?: latestSummary?.sessionId + ?: lastRecordedSessionId + } val fallbackSummary = remember(session, resolvedSessionId) { session?.let { s -> if (s.avgHeartRateBpm != null || s.maxHeartRateBpm != null || s.minHeartRateBpm != null || @@ -546,7 +555,9 @@ fun PolarSessionAnalyticsCard( ) { TextButton( onClick = { - resolvedSessionId?.let { polarBleManager?.triggerCooldown(it, forceRestart = true) } + val targetId = polarBleManager?.lastRecordedSessionId?.value + ?: resolvedSessionId + polarBleManager?.triggerCooldown(targetId, forceRestart = true) }, ) { Icon( @@ -577,12 +588,10 @@ fun PolarSessionAnalyticsCard( ) FilledTonalButton( onClick = { - val targetId = resolvedSessionId - ?: polarBleManager?.lastRecordedSessionId?.value + val targetId = polarBleManager?.lastRecordedSessionId?.value + ?: resolvedSessionId ?: polarBleManager?.latestSessionSummary?.value?.sessionId - if (targetId != null) { - polarBleManager?.triggerCooldown(targetId, forceRestart = true) - } + polarBleManager?.triggerCooldown(targetId, forceRestart = true) }, shape = MaterialTheme.shapes.small, contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp), 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 2becac7c1..e035fcad9 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 @@ -261,8 +261,18 @@ fun RoutineCompleteScreen( } // 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) { + if (lastRecordedSessionId != null) { + workoutHistory.firstOrNull { it.id == lastRecordedSessionId } + } else { + workoutHistory.maxByOrNull { it.timestamp } + } + } PolarSessionAnalyticsCard( - sessionId = polarBleManager?.lastRecordedSessionId?.collectAsState()?.value, + sessionId = lastRecordedSessionId ?: latestCompletedSession?.id, + session = latestCompletedSession, modifier = Modifier.fillMaxWidth(), polarBleManager = polarBleManager, workoutRepository = viewModel.workoutRepository, From 50873a818d9ea7ed0f0dab4a8b1fc6993aa8d688 Mon Sep 17 00:00:00 2001 From: StuGotz Date: Wed, 28 Oct 2026 14:25:28 -0500 Subject: [PATCH 20/25] fix(polar): remove committedSessionIds and apply residual PR #1326 review fixes --- .../data/ble/PolarH10BleManager.kt | 81 +++++++------------ .../data/ble/CompositionGattFake.kt | 5 +- .../data/ble/PolarH10BleManagerTest.kt | 43 +++------- .../components/PolarSessionAnalyticsCard.kt | 5 +- 4 files changed, 48 insertions(+), 86 deletions(-) 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 index 1e0f20817..4f7d9ccca 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -215,7 +215,6 @@ class PolarH10BleManager( val latestSessionSummary: StateFlow = _latestSessionSummary.asStateFlow() private val completedCooldownSessionIds = mutableSetOf() - private val committedSessionIds = mutableSetOf() private var completedCooldownSessionId: String? = null val currentRecordingSessionId: String? get() = activeSessionId @@ -231,7 +230,6 @@ class PolarH10BleManager( _cooldownStartHeartRate.value = null _activeCooldownSessionId.value = null completedCooldownSessionIds.remove(sessionId) - committedSessionIds.remove(sessionId) completedCooldownSessionId = null activeSessionId = sessionId recordedSessionId = sessionId @@ -269,11 +267,8 @@ class PolarH10BleManager( */ fun flushSessionTelemetry(sessionId: String) { val (beatsToFlush, ecgsToFlush) = sessionLock.withLock { - committedSessionIds.add(sessionId) - activeSessionId = if (_isCooldownActive.value && _activeCooldownSessionId.value == sessionId) { - sessionId - } else { - null + if (!_isCooldownActive.value) { + activeSessionId = null } recordedSessionId = sessionId _lastRecordedSessionId.value = sessionId @@ -325,7 +320,7 @@ class PolarH10BleManager( */ fun discardActiveRecording() { val targetSessionId = activeSessionId ?: return - if (_isCooldownActive.value || committedSessionIds.contains(targetSessionId)) return + if (_isCooldownActive.value) return sessionLock.withLock { bufferedBeatSamples.clear() bufferedEcgSegments.clear() @@ -394,17 +389,9 @@ class PolarH10BleManager( } fun triggerCooldown(sessionId: String? = null, forceRestart: Boolean = false) { - val knownRecordedId = recordedSessionId ?: _lastRecordedSessionId.value - val candidateId: String = sessionId - ?: knownRecordedId - ?: (if (forceRestart) completedCooldownSessionId else null) - ?: return - val targetId: String = when { - candidateId == knownRecordedId -> candidateId - forceRestart && candidateId == completedCooldownSessionId -> candidateId - committedSessionIds.contains(candidateId) -> knownRecordedId ?: candidateId - else -> return - } + val knownRecordedId = _lastRecordedSessionId.value ?: recordedSessionId ?: completedCooldownSessionId + val targetId: String = sessionId ?: knownRecordedId ?: return + if (targetId != knownRecordedId) return if (forceRestart) { completedCooldownSessionIds.remove(targetId) } @@ -457,20 +444,9 @@ class PolarH10BleManager( } fun endSessionRecording(sessionId: String? = null, forceRestart: Boolean = false) { - val knownRecordedId = recordedSessionId ?: _lastRecordedSessionId.value - val candidateId: String = sessionId - ?: knownRecordedId - ?: (if (forceRestart) completedCooldownSessionId else null) - ?: return - val targetSessionId: String = when { - candidateId == knownRecordedId -> candidateId - forceRestart && candidateId == completedCooldownSessionId -> candidateId - committedSessionIds.contains(candidateId) -> knownRecordedId ?: candidateId - else -> return - } - if (forceRestart && targetSessionId == completedCooldownSessionId) { - completedCooldownSessionIds.remove(targetSessionId) - } + val knownRecordedId = _lastRecordedSessionId.value ?: recordedSessionId ?: completedCooldownSessionId + val targetSessionId: String = sessionId ?: knownRecordedId ?: return + if (targetSessionId != knownRecordedId) return if (!forceRestart && completedCooldownSessionIds.contains(targetSessionId)) { Logger.d { "endSessionRecording: Cooldown already completed for session $targetSessionId; skipping" } return @@ -735,6 +711,8 @@ class PolarH10BleManager( internal val isReconnectJobActiveForTesting: Boolean get() = reconnectJob?.isActive == true private var scanJob: Job? = null + + @Volatile private var reconnectJob: Job? = null private val discoveredAdvertisements = mutableMapOf() @@ -874,9 +852,8 @@ class PolarH10BleManager( if (reconnectJob?.isActive == true) { return } - reconnectJob = scope.launch { - autoReconnectIfRemembered() - } + reconnectJob = scope.launch { autoReconnectIfRemembered() } + .also { job -> job.invokeOnCompletion { if (reconnectJob === job) reconnectJob = null } } } /** @@ -949,7 +926,6 @@ class PolarH10BleManager( val currentJob = currentCoroutineContext()[Job] if (reconnectJob !== currentJob) { reconnectJob?.cancel() - reconnectJob = null } val wasScanning = scanJob?.isActive == true || _connectionState.value is ConnectionState.Scanning @@ -1149,7 +1125,8 @@ class PolarH10BleManager( 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." } - if (previousContactDetected == true) { + val hadContact = previousContactDetected == true + if (previousContactDetected != false) { previousContactDetected = false if (!measurement.contactDetected) { logRepo.warning( @@ -1158,11 +1135,13 @@ class PolarH10BleManager( tag = "HRM", ) } - logRepo.debug( - eventType = LogEventType.DIAGNOSTIC, - message = "[HRM] Contact lost (contact: true -> false)", - tag = "HRM", - ) + if (hadContact) { + logRepo.debug( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] Contact lost (contact: true -> false)", + tag = "HRM", + ) + } } _isContactDetected.value = false clearCachedTelemetry() @@ -1207,7 +1186,8 @@ class PolarH10BleManager( return } if (!measurement.contactDetected || measurement.hr <= 0 || isBatteryCritical()) { - if (previousContactDetected == true) { + val hadContact = previousContactDetected == true + if (previousContactDetected != false) { previousContactDetected = false if (!measurement.contactDetected) { logRepo.warning( @@ -1216,11 +1196,13 @@ class PolarH10BleManager( tag = "HRM", ) } - logRepo.debug( - eventType = LogEventType.DIAGNOSTIC, - message = "[HRM] Contact lost (contact: true -> false)", - tag = "HRM", - ) + if (hadContact) { + logRepo.debug( + eventType = LogEventType.DIAGNOSTIC, + message = "[HRM] Contact lost (contact: true -> false)", + tag = "HRM", + ) + } } _isContactDetected.value = false clearCachedTelemetry() @@ -1811,7 +1793,6 @@ class PolarH10BleManager( peripheral = null p.disconnect() } catch (e: Exception) { - peripheral = null e.rethrowIfCancellation() log.w { "Error during Polar disconnect: ${e.message}" } } 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 index 8bb5353e2..3ba120c61 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt @@ -6,9 +6,6 @@ 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.util.BleConstants -import com.juul.kable.Advertisement -import com.juul.kable.Identifier -import com.juul.kable.ManufacturerData import com.russhwolf.settings.MapSettings import kotlin.test.Test import kotlin.test.assertEquals @@ -17,7 +14,6 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -34,36 +30,19 @@ import kotlinx.coroutines.test.runTest @OptIn(ExperimentalUuidApi::class) class PolarH10BleManagerTest { - private class FakeAdvertisement( - id: String = "00000000-0000-0000-0000-001122334455", - override val name: String? = null, - override val uuids: List = emptyList(), - override val rssi: Int = -60, - ) : Advertisement { - private val uuid: Any = Uuid.parse(id) - @Suppress("USELESS_CAST") - override val identifier: Identifier = if (uuid is Identifier) uuid else id as Identifier - override val peripheralName: String? get() = name - override val isConnectable: Boolean get() = true - override val txPower: Int? get() = null - override val manufacturerData: ManufacturerData? get() = null - override fun serviceData(uuid: Uuid): ByteArray? = null - override fun manufacturerData(companyIdentifierCode: Int): ByteArray? = null - } - // ==================== Advertisement Filter Tests ==================== @Test fun `isPolarH10Advertisement matches Polar H10 device names case-insensitively`() { val manager = PolarH10BleManager() - assertTrue(manager.isPolarH10Advertisement(FakeAdvertisement(name = "Polar H10 12345678"))) - assertTrue(manager.isPolarH10Advertisement(FakeAdvertisement(name = "polar h10 9999"))) - assertTrue(manager.isPolarH10Advertisement(FakeAdvertisement(name = "POLAR H10"))) + 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(FakeAdvertisement(name = "Vee_Trainer"))) - assertFalse(manager.isPolarH10Advertisement(FakeAdvertisement(name = "VIT-200"))) - assertFalse(manager.isPolarH10Advertisement(FakeAdvertisement(name = null))) + 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 @@ -71,10 +50,10 @@ class PolarH10BleManagerTest { val manager = PolarH10BleManager() val hrUuid = BleConstants.Polar.HR_SERVICE_UUID - assertTrue(manager.isPolarH10Advertisement(FakeAdvertisement(name = null, uuids = listOf(hrUuid)))) + assertTrue(manager.isPolarH10Advertisement(CompositionAdvertisement(name = null, uuids = listOf(hrUuid)))) val nusUuid = BleConstants.NUS_SERVICE_UUID - assertFalse(manager.isPolarH10Advertisement(FakeAdvertisement(name = "Unknown", uuids = listOf(nusUuid)))) + assertFalse(manager.isPolarH10Advertisement(CompositionAdvertisement(name = "Unknown", uuids = listOf(nusUuid)))) } // ==================== Initial State Tests ==================== @@ -841,7 +820,7 @@ class PolarH10BleManagerTest { @Test fun `startSessionRecording resets isExplicitDisconnect and auto-reconnects to remembered sensor`() { - val fakeAd = FakeAdvertisement( + val fakeAd = CompositionAdvertisement( id = "00000000-0000-0000-0000-24acac0e5b59", name = "Polar H10 12345678", uuids = listOf(BleConstants.Polar.HR_SERVICE_UUID), @@ -1034,6 +1013,10 @@ class PolarH10BleManagerTest { 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() 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 index cc3dd042e..16ecebf32 100644 --- a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt @@ -45,7 +45,6 @@ import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.ui.theme.Spacing import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.koin.mp.KoinPlatformTools import kotlin.math.abs import kotlin.math.roundToInt @@ -67,9 +66,7 @@ fun PolarSessionAnalyticsCard( showCooldown: Boolean = true, modifier: Modifier = Modifier, polarBleManager: PolarH10BleManager? = null, - workoutRepository: WorkoutRepository? = remember { - KoinPlatformTools.defaultContext().getOrNull()?.getOrNull() - }, + workoutRepository: WorkoutRepository? = null, ) { val connectionState = polarBleManager?.connectionState?.collectAsState()?.value ?: ConnectionState.Disconnected val isCooldownActive = polarBleManager?.isCooldownActive?.collectAsState()?.value ?: false From afbbcbace207b977c4f0f523b40c7bc270ac0a3c Mon Sep 17 00:00:00 2001 From: StuGotz Date: Wed, 28 Oct 2026 14:59:22 -0500 Subject: [PATCH 21/25] fix(polar): address latest kilo-code PR review feedback --- .../data/ble/PolarH10BleManager.kt | 82 +++++++------------ .../manager/DefaultWorkoutSessionManager.kt | 7 +- .../data/ble/PolarH10BleManagerTest.kt | 64 ++------------- .../components/PolarSessionAnalyticsCard.kt | 15 ++-- .../screen/RoutineCompleteScreen.kt | 8 +- 5 files changed, 48 insertions(+), 128 deletions(-) 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 index 4f7d9ccca..95a2ec07a 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -392,12 +392,6 @@ class PolarH10BleManager( val knownRecordedId = _lastRecordedSessionId.value ?: recordedSessionId ?: completedCooldownSessionId val targetId: String = sessionId ?: knownRecordedId ?: return if (targetId != knownRecordedId) return - if (forceRestart) { - completedCooldownSessionIds.remove(targetId) - } - recordedSessionId = targetId - activeSessionId = targetId - _lastRecordedSessionId.value = targetId endSessionRecording(targetId, forceRestart = forceRestart) if (_connectionState.value is ConnectionState.Connected) { scope.launch { @@ -1125,30 +1119,7 @@ class PolarH10BleManager( 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." } - 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) { - log.i { "Terminating recovery cooldown due to strap removal or critical battery" } - cancelCooldown() - } + handleNoContact(measurement) return@collect } @@ -1176,6 +1147,33 @@ class PolarH10BleManager( } } + private fun handleNoContact(measurement: PolarHrMeasurement) { + 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) { + log.i { "Terminating recovery cooldown due to strap removal or critical battery" } + cancelCooldown() + } + } + /** * Reconstructs accurate R-wave beat timestamps by chaining RR intervals together, * eliminating packet jitter across connection intervals. Automatically re-anchors @@ -1186,29 +1184,7 @@ class PolarH10BleManager( return } if (!measurement.contactDetected || measurement.hr <= 0 || isBatteryCritical()) { - 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) { - cancelCooldown() - } + handleNoContact(measurement) return } if (previousContactDetected != true) { 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 7be4bbf6d..96a77d385 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 @@ -534,7 +534,7 @@ class DefaultWorkoutSessionManager( is WorkoutState.Completed, WorkoutState.RoutineComplete -> { if (polarBleManager.isConnected.value) { polarBleManager.endSessionRecording( - coordinator.currentSessionId ?: polarBleManager.lastRecordedSessionId.value, + polarBleManager.lastRecordedSessionId.value ?: coordinator.currentSessionId, ) } } @@ -542,8 +542,7 @@ class DefaultWorkoutSessionManager( polarBleManager.updateSessionPhase("REST") } is WorkoutState.Idle -> { - if (polarBleManager.currentRecordingSessionId != null && - !polarBleManager.isCooldownActive.value && + if (!polarBleManager.isCooldownActive.value && coordinator.routineFlowState.value !is RoutineFlowState.Complete) { polarBleManager.discardActiveRecording() } @@ -563,7 +562,7 @@ class DefaultWorkoutSessionManager( if (flowState is RoutineFlowState.Complete) { if (polarBleManager.isConnected.value) { polarBleManager.endSessionRecording( - coordinator.currentSessionId ?: polarBleManager.lastRecordedSessionId.value, + polarBleManager.lastRecordedSessionId.value ?: coordinator.currentSessionId, ) } } 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 index 3ba120c61..c3e9fb909 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt @@ -1,10 +1,13 @@ package com.devil.phoenixproject.data.ble +import com.devil.phoenixproject.data.repository.SqlDelightWorkoutRepository 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.russhwolf.settings.MapSettings import kotlin.test.Test @@ -930,67 +933,12 @@ class PolarH10BleManagerTest { val set1Id = "ea838be2-3983-4ec3-8eb4-c8a75fdd52a2" val set2Id = "9615342a-142c-4cd7-838e-5cf38ab9b7f7" - - fun insertSessionRow(id: String, ts: Long) { - database.phoenixDatabaseQueries.insertSession( - id = id, - timestamp = ts, - mode = "OldSchool", - targetReps = 10L, - weightPerCableKg = 20.0, - progressionKg = 0.0, - duration = 30_000L, - totalReps = 10L, - warmupReps = 0L, - workingReps = 10L, - isJustLift = 0L, - stopAtTop = 0L, - eccentricLoad = 100L, - echoLevel = 1L, - exerciseId = null, - exerciseName = "Bench Press", - routineSessionId = "c1b268f6-6402-4a07-b50d-48ed76a023bc", - routineName = "Test Routine", - 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 = "[]", - ) - } + 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))) - insertSessionRow(set1Id, simulatedTime) + workoutRepo.saveSession(WorkoutSession(id = set1Id, timestamp = simulatedTime)) manager.flushSessionTelemetry(set1Id) assertNull(manager.currentRecordingSessionId, "flushSessionTelemetry must clear activeSessionId once committed") assertEquals(set1Id, manager.lastRecordedSessionId.value) @@ -999,7 +947,7 @@ class PolarH10BleManagerTest { simulatedTime += 60_000L manager.startSessionRecording(set2Id, "WORKING_SET") manager.processRrIntervals(PolarHrMeasurement(hr = 165, contactDetected = true, rrIntervalsMs = listOf(720.0))) - insertSessionRow(set2Id, simulatedTime) + workoutRepo.saveSession(WorkoutSession(id = set2Id, timestamp = simulatedTime)) manager.flushSessionTelemetry(set2Id) assertNull(manager.currentRecordingSessionId) assertEquals(set2Id, manager.lastRecordedSessionId.value) 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 index 16ecebf32..01194b760 100644 --- a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt @@ -552,9 +552,10 @@ fun PolarSessionAnalyticsCard( ) { TextButton( onClick = { - val targetId = polarBleManager?.lastRecordedSessionId?.value - ?: resolvedSessionId - polarBleManager?.triggerCooldown(targetId, forceRestart = true) + polarBleManager?.triggerCooldown( + polarBleManager.lastRecordedSessionId.value, + forceRestart = true, + ) }, ) { Icon( @@ -585,10 +586,10 @@ fun PolarSessionAnalyticsCard( ) FilledTonalButton( onClick = { - val targetId = polarBleManager?.lastRecordedSessionId?.value - ?: resolvedSessionId - ?: polarBleManager?.latestSessionSummary?.value?.sessionId - polarBleManager?.triggerCooldown(targetId, forceRestart = true) + polarBleManager?.triggerCooldown( + polarBleManager.lastRecordedSessionId.value, + forceRestart = true, + ) }, shape = MaterialTheme.shapes.small, contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp), 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 e035fcad9..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 @@ -264,14 +264,10 @@ fun RoutineCompleteScreen( val lastRecordedSessionId = polarBleManager?.lastRecordedSessionId?.collectAsState()?.value val workoutHistory by viewModel.workoutHistory.collectAsState() val latestCompletedSession = remember(lastRecordedSessionId, workoutHistory) { - if (lastRecordedSessionId != null) { - workoutHistory.firstOrNull { it.id == lastRecordedSessionId } - } else { - workoutHistory.maxByOrNull { it.timestamp } - } + lastRecordedSessionId?.let { id -> workoutHistory.firstOrNull { it.id == id } } } PolarSessionAnalyticsCard( - sessionId = lastRecordedSessionId ?: latestCompletedSession?.id, + sessionId = lastRecordedSessionId, session = latestCompletedSession, modifier = Modifier.fillMaxWidth(), polarBleManager = polarBleManager, From f68606765be3e37c4d66cc35e7d77b9e788cecd2 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 29 Oct 2026 17:35:45 -0400 Subject: [PATCH 22/25] fix(polar): resolve telemetry lifecycle and review findings --- CLAUDE.md | 1 + docs/qa/persisted-file-upgrade.md | 2 +- docs/qa/polar-h10-release.md | 25 + docs/qa/polar-h10-review-1326.md | 56 ++ docs/qa/profile-schema42-fixture.md | 6 +- .../qa/Invoke-PhoenixPersistedFileUpgrade.ps1 | 2 +- .../data/ble/PolarH10PersistenceTest.kt | 157 ++++- .../SqlDelightPolarTelemetryRepositoryTest.kt | 140 +++++ .../PolarSessionAnalyticsCardRuntimeTest.kt | 77 +++ .../util/DataBackupCompletenessTest.kt | 75 ++- .../data/ble/PolarH10BleManager.kt | 588 ++++++++++-------- .../data/ble/PolarProtocolParser.kt | 63 +- .../repository/PolarTelemetryRepository.kt | 16 + .../SqlDelightPolarTelemetryRepository.kt | 80 +++ .../repository/SqlDelightWorkoutRepository.kt | 45 +- .../data/repository/WorkoutRepository.kt | 10 +- .../com/devil/phoenixproject/di/DataModule.kt | 3 +- .../phoenixproject/domain/model/Models.kt | 4 +- .../domain/model/PolarTelemetryRecords.kt | 35 ++ .../manager/ActiveSessionEngine.kt | 25 +- .../manager/DefaultWorkoutSessionManager.kt | 8 +- .../presentation/viewmodel/MainViewModel.kt | 1 - .../devil/phoenixproject/util/BackupModels.kt | 4 +- .../phoenixproject/util/DataBackupManager.kt | 20 + .../database/PhoenixDatabase.sq | 22 + .../data/ble/PolarH10BleManagerTest.kt | 295 ++++++++- .../data/ble/PolarProtocolParserTest.kt | 67 +- .../manager/ManualStopStartTimeRaceTest.kt | 28 + .../screen/Issue687WorkoutStartGateUiTest.kt | 12 +- .../testutil/DWSMTestHarness.kt | 3 + .../testutil/FakeWorkoutRepository.kt | 10 +- .../components/PolarH10SettingsCard.kt | 138 +--- .../components/PolarH10WorkoutStrip.kt | 8 +- .../components/PolarSessionAnalyticsCard.kt | 129 ++-- .../screen/ActiveWorkoutScreen.kt | 4 - .../presentation/screen/HistoryTab.kt | 22 +- .../presentation/screen/WorkoutTab.kt | 2 +- 37 files changed, 1634 insertions(+), 549 deletions(-) create mode 100644 docs/qa/polar-h10-release.md create mode 100644 docs/qa/polar-h10-review-1326.md create mode 100644 shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightPolarTelemetryRepositoryTest.kt create mode 100644 shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCardRuntimeTest.kt create mode 100644 shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/PolarTelemetryRepository.kt create mode 100644 shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightPolarTelemetryRepository.kt create mode 100644 shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/PolarTelemetryRecords.kt 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 index ab5dd22e8..08fd35d2a 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10PersistenceTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10PersistenceTest.kt @@ -1,6 +1,11 @@ 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 @@ -9,6 +14,9 @@ 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 @@ -97,10 +105,12 @@ class PolarH10PersistenceTest { @Test fun `telemetry is buffered before session exists and flushed after session creation with foreign keys ON`() { val manager = PolarH10BleManager( - database = database, + 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 @@ -176,7 +186,7 @@ class PolarH10PersistenceTest { @Test fun `abandoned set discards buffered telemetry cleanly`() { val manager = PolarH10BleManager( - database = database, + telemetryRepository = SqlDelightPolarTelemetryRepository(database, { simulatedTime }, Dispatchers.Unconfined), scope = testScope, timeSource = { simulatedTime }, ) @@ -202,4 +212,147 @@ class PolarH10PersistenceTest { 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/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/util/DataBackupCompletenessTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupCompletenessTest.kt index f3e82f234..927e622d8 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupCompletenessTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupCompletenessTest.kt @@ -244,7 +244,7 @@ class DataBackupCompletenessTest { // ---- polar telemetry completeness ---- @Test - fun `polar summaries samples and ecg traces survive backup and restore even when raw telemetry is false`() = runTest { + 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") @@ -261,7 +261,6 @@ class DataBackupCompletenessTest { maxTorsoPitchDeg = 32.5, maxTorsoRollDeg = 15.1, ) - // 1 normal sample (should be excluded because includeRawTelemetry = false and no polar fields) source.queries.insertMetric( sessionId = "session-polar-1", timestamp = 1_700_000_001_000L, @@ -274,7 +273,6 @@ class DataBackupCompletenessTest { power = 50.0, status = 0L, ) - // 1 sample WITH Polar data (must be included even when includeRawTelemetry = false) source.queries.insertMetricWithPolar( sessionId = "session-polar-1", timestamp = 1_700_000_002_000L, @@ -395,6 +393,77 @@ class DataBackupCompletenessTest { 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/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt index 95a2ec07a..b4dabc5b1 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -4,9 +4,12 @@ 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.database.PhoenixDatabase +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 @@ -26,11 +29,13 @@ 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.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay @@ -50,8 +55,8 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.withContext /** * Summary metrics for a session recorded by Polar H10. @@ -81,13 +86,14 @@ data class PolarSessionSummary( */ class PolarH10BleManager( private val settings: Settings? = null, - private val database: PhoenixDatabase? = 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") @@ -100,6 +106,12 @@ class PolarH10BleManager( 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 @@ -123,6 +135,19 @@ class PolarH10BleManager( _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) @@ -189,6 +214,40 @@ class PolarH10BleManager( 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() @@ -216,10 +275,24 @@ class PolarH10BleManager( private val completedCooldownSessionIds = mutableSetOf() private var completedCooldownSessionId: String? = null - - val currentRecordingSessionId: String? get() = activeSessionId - - fun startSessionRecording(sessionId: String, phase: String = "WARMUP") { + 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 @@ -243,32 +316,54 @@ class PolarH10BleManager( 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 { - if (!_isEcgStreaming.value) startEcgStream() - if (!_isAccStreaming.value) startAccStream() - } + 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 to the database within a transaction once the parent - * [WorkoutSession] row is committed. Subsequent records will insert directly into SQLite. + * Flush buffered telemetry records once the parent row is committed. + * The returned job finishes after the queued IO transaction. */ - fun flushSessionTelemetry(sessionId: String) { + 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 @@ -280,35 +375,56 @@ class PolarH10BleManager( } _latestSessionSummary.value = getSessionSummary(sessionId) - if (database != null && (beatsToFlush.isNotEmpty() || ecgsToFlush.isNotEmpty())) { - try { - database.transaction { - for (b in beatsToFlush) { - database.phoenixDatabaseQueries.insertPolarBeatSample( - sessionId = sessionId, - timestampMs = b.timestampMs, - phase = b.phase, - hrBpm = b.hrBpm, - rrMs = b.rrMs, - rmssdMs = b.rmssdMs, - torsoPitchDeg = b.torsoPitchDeg, - torsoRollDeg = b.torsoRollDeg, - dynamicAccMg = b.dynamicAccMg, - ) - } - for (seg in ecgsToFlush) { - database.phoenixDatabaseQueries.insertPolarEcgSegment( - sessionId = sessionId, - sensorTimestampNs = seg.sensorTimestampNs, - wallClockTimestampMs = seg.wallClockTimestampMs, - phase = seg.phase, - samplesUvCsv = seg.samplesUvCsv, + 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 } } - log.i { "Flushed ${beatsToFlush.size} beat samples and ${ecgsToFlush.size} ECG segments for session $sessionId" } - } catch (e: Exception) { - log.e(e) { "Failed to flush buffered Polar telemetry for session $sessionId: ${e.message}" } } } } @@ -318,7 +434,7 @@ class PolarH10BleManager( * 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() { + fun discardActiveRecording(): Unit = sessionLock.withLock { val targetSessionId = activeSessionId ?: return if (_isCooldownActive.value) return sessionLock.withLock { @@ -347,74 +463,45 @@ class PolarH10BleManager( internal fun getBufferedEcgSegmentsForTesting(): List = sessionLock.withLock { bufferedEcgSegments.toList() } - fun updateSessionPhase(phase: String) { + fun updateSessionPhase(phase: String): Unit = sessionLock.withLock { if (phase == "WORKING_SET" || phase == "WARMUP") { if (_isCooldownActive.value || currentPhase == "POST_WORKOUT_COOLDOWN") { - cooldownJob?.cancel() - cooldownJob = null - cooldownStartTimestampMs = null - _isCooldownActive.value = false - _cooldownSecondsRemaining.value = 0 - val targetSessionId = activeSessionId ?: _activeCooldownSessionId.value - if (targetSessionId != null) { - completedCooldownSessionIds.remove(targetSessionId) - } - _activeCooldownSessionId.value = null - if (activeSessionId == null && recordedSessionId != null) { - activeSessionId = recordedSessionId - } + stopCooldown() } } currentPhase = phase } fun cancelCooldown() { - cooldownJob?.cancel() - cooldownJob = null - cooldownStartTimestampMs = null - _isCooldownActive.value = false - _cooldownSecondsRemaining.value = 0 - val targetSessionId = activeSessionId ?: _activeCooldownSessionId.value - if (targetSessionId != null) { - completedCooldownSessionIds.remove(targetSessionId) - } - activeSessionId = null - _activeCooldownSessionId.value = null - recordedSessionId = null - completedCooldownSessionId = null - scope.launch { - stopEcgStream() - stopAccStream() - } + sessionLock.withLock { pendingCooldownRequest = null } + if (_isCooldownActive.value) stopCooldown() } - fun triggerCooldown(sessionId: String? = null, forceRestart: Boolean = false) { - val knownRecordedId = _lastRecordedSessionId.value ?: recordedSessionId ?: completedCooldownSessionId - val targetId: String = sessionId ?: knownRecordedId ?: return - if (targetId != knownRecordedId) return - endSessionRecording(targetId, forceRestart = forceRestart) + 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 { - if (!_isEcgStreaming.value) startEcgStream() - if (!_isAccStreaming.value) startAccStream() - } + scope.launch { ensureRecordingTelemetryStreams() } } } - fun stopCooldown() { + 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 + 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) { + if (hrr60 == null && elapsedMs >= 60_000L) { hrr60 = drop _cooldownHrr60.value = drop } - if (hrr300 == null) { + if (hrr300 == null && elapsedMs >= 300_000L) { hrr300 = drop _cooldownHrr300.value = drop } @@ -430,28 +517,32 @@ class PolarH10BleManager( } activeSessionId = null _activeCooldownSessionId.value = null - recordedSessionId = null - scope.launch { - stopEcgStream() - stopAccStream() - } + scope.launch { stopUnusedTelemetryStreams() } } - fun endSessionRecording(sessionId: String? = null, forceRestart: Boolean = false) { + fun endSessionRecording(sessionId: String? = null, forceRestart: Boolean = false): Boolean = sessionLock.withLock { val knownRecordedId = _lastRecordedSessionId.value ?: recordedSessionId ?: completedCooldownSessionId - val targetSessionId: String = sessionId ?: knownRecordedId ?: return - if (targetSessionId != knownRecordedId) return + 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 + return false } if (!forceRestart && _isCooldownActive.value && _activeCooldownSessionId.value == targetSessionId) { Logger.d { "endSessionRecording: Cooldown already active for session $targetSessionId; skipping restart" } - return + 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 @@ -472,71 +563,34 @@ class PolarH10BleManager( val cooldownStartMs = monotonicClock() cooldownStartTimestampMs = cooldownStartMs cooldownJob = scope.launch { - var hrr60Persisted = false - var hrr300Persisted = false - while (isActive && (monotonicClock() - cooldownStartMs) < 300_000L) { + while (isActive) { delay(1000L) - if (peripheral != null && !_isConnected.value) { - log.i { "Polar H10 disconnected during recovery cooldown: terminating recovery window" } - cancelCooldown() - break - } - checkPacketAbsenceTimeout(monotonicClock()) - if (!_isCooldownActive.value) { - break - } - val elapsedMs = monotonicClock() - cooldownStartMs - val remainingSec = (300 - (elapsedMs / 1000)).toInt().coerceAtLeast(0) - _cooldownSecondsRemaining.value = remainingSec - - val curHr = _hrMeasurement.value?.hr - // Bug C: Anchor peak HR during the first 30 seconds of cooldown - if (elapsedMs <= 30_000L && curHr != null) { - val peak = maxOf(endHeartRate ?: curHr, curHr) - if (endHeartRate != peak) { - endHeartRate = peak - _cooldownStartHeartRate.value = peak + 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 } } - val endHr = endHeartRate - if (elapsedMs >= 60_000L && !hrr60Persisted && curHr != null && endHr != null) { - val drop = (endHr - curHr).coerceAtLeast(0) - hrr60 = drop - _cooldownHrr60.value = drop - hrr60Persisted = true - persistPolarSummary(targetSessionId) - } - if (elapsedMs >= 300_000L && !hrr300Persisted && curHr != null && endHr != null) { - val drop = (endHr - curHr).coerceAtLeast(0) - hrr300 = drop - _cooldownHrr300.value = drop - hrr300Persisted = true - persistPolarSummary(targetSessionId) - } - _latestSessionSummary.value = getSessionSummary(targetSessionId) } - if (!_isCooldownActive.value) { - return@launch - } - val finalHr = _hrMeasurement.value?.hr - if (!hrr300Persisted && finalHr != null && endHeartRate != null) { - val drop = (endHeartRate!! - finalHr).coerceAtLeast(0) - hrr300 = drop - _cooldownHrr300.value = drop - persistPolarSummary(targetSessionId) - } - completedCooldownSessionIds.add(targetSessionId) - completedCooldownSessionId = targetSessionId - _isCooldownActive.value = false - _cooldownSecondsRemaining.value = 0 - _latestSessionSummary.value = getSessionSummary(targetSessionId) - flushSessionTelemetry(targetSessionId) - activeSessionId = null - _activeCooldownSessionId.value = null - recordedSessionId = null - stopEcgStream() - stopAccStream() } + true } private data class SessionSampleSnapshot( @@ -546,7 +600,13 @@ class PolarH10BleManager( val rollMax: Double?, ) - fun getSessionSummary(sessionId: String): PolarSessionSummary? { + 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( @@ -578,22 +638,7 @@ class PolarH10BleManager( private fun persistPolarSummary(sessionId: String) { val summary = getSessionSummary(sessionId) ?: return - try { - database?.phoenixDatabaseQueries?.updateSessionPolarSummary( - id = sessionId, - avgHeartRateBpm = summary.avgHeartRateBpm?.toLong(), - maxHeartRateBpm = summary.maxHeartRateBpm?.toLong(), - minHeartRateBpm = summary.minHeartRateBpm?.toLong(), - avgRrIntervalMs = summary.avgRrIntervalMs?.toDouble(), - rmssdMs = summary.rmssdMs?.toDouble(), - hrr60Bpm = summary.hrr60Bpm?.toLong(), - hrr300Bpm = summary.hrr300Bpm?.toLong(), - maxTorsoPitchDeg = summary.maxTorsoPitchDeg?.toDouble(), - maxTorsoRollDeg = summary.maxTorsoRollDeg?.toDouble(), - ) - } catch (e: Exception) { - log.w { "Failed to update session polar summary: ${e.message}" } - } + queueTelemetry(PendingTelemetry(sessionId, recovery = summary)) } // ===== Connection & Discovery State ===== @@ -708,8 +753,14 @@ class PolarH10BleManager( @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 @@ -717,6 +768,23 @@ class PolarH10BleManager( // ===== 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, @@ -764,7 +832,7 @@ class PolarH10BleManager( scanJob?.cancel() scanJob = null _scannedDevices.value = emptyList() - discoveredAdvertisements.clear() + advertisementLock.withLock { discoveredAdvertisements.clear() } _connectionState.value = ConnectionState.Scanning @@ -777,7 +845,7 @@ class PolarH10BleManager( .filter { adv -> isPolarH10Advertisement(adv) } .collect { adv -> val identifier = adv.identifier.toString() - discoveredAdvertisements[identifier] = adv + advertisementLock.withLock { discoveredAdvertisements[identifier] = adv } val name = adv.name ?: "Polar H10" val scanned = ScannedDevice(name = name, address = identifier, rssi = adv.rssi) @@ -835,11 +903,11 @@ class PolarH10BleManager( private class AutoReconnectMatchFound(val device: ScannedDevice) : CancellationException() /** - * Resets explicit disconnect suppression and triggers an immediate auto-reconnect attempt + * Triggers an immediate auto-reconnect attempt unless explicitly disconnected * to a remembered Polar sensor if not already connecting or reconnecting. */ fun ensureConnectedOrReconnect() { - isExplicitDisconnect = false + if (isExplicitDisconnect) return if (_connectionState.value is ConnectionState.Connected || _connectionState.value is ConnectionState.Connecting) { return } @@ -869,8 +937,7 @@ class PolarH10BleManager( val name = _rememberedName.value ?: "Polar H10" log.i { "Attempting auto-reconnect to remembered Polar H10: $name ($address)" } - val cachedAdv = discoveredAdvertisements[address] - ?: discoveredAdvertisements.values.firstOrNull { it.identifier.toString() == address } + val cachedAdv = cachedAdvertisement(address) if (cachedAdv != null) { val connectResult = connect(ScannedDevice(name = name, address = address)) if (connectResult.isSuccess) { @@ -887,7 +954,7 @@ class PolarH10BleManager( .filter { isPolarH10Advertisement(it) } .collect { adv -> val id = adv.identifier.toString() - discoveredAdvertisements[id] = adv + 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!!) @@ -928,8 +995,7 @@ class PolarH10BleManager( delay(150) } - val advertisement = discoveredAdvertisements[device.address] - ?: discoveredAdvertisements.values.firstOrNull { it.identifier.toString() == device.address } + val advertisement = cachedAdvertisement(device.address) ?: return Result.failure(IllegalStateException("Polar device ${device.address} not found in scanned cache")) logRepo.info( @@ -1007,13 +1073,19 @@ class PolarH10BleManager( } } - withTimeout(BleConstants.CONNECTION_TIMEOUT_MS) { + 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( @@ -1024,8 +1096,6 @@ class PolarH10BleManager( details = e.stackTraceToString(), tag = "HRM", ) - _isConnected.value = false - cleanupExistingConnection() _connectionState.value = ConnectionState.Error("Connection failed: ${e.message}", e) Result.failure(e) } @@ -1094,8 +1164,7 @@ class PolarH10BleManager( } if (activeSessionId != null) { - if (!_isAccStreaming.value) scope.launch { startAccStream() } - if (!_isEcgStreaming.value) scope.launch { startEcgStream() } + scope.launch { ensureRecordingTelemetryStreams() } } } @@ -1133,12 +1202,12 @@ class PolarH10BleManager( } _isContactDetected.value = true + contactLossJob?.cancel() + contactLossJob = null lastValidPacketTimestampMs = monotonicClock() _hrMeasurement.value = measurement - if (measurement.rrIntervalsMs.isNotEmpty()) { - processRrIntervals(measurement) - } + processRrIntervals(measurement) } } catch (e: Exception) { e.rethrowIfCancellation() @@ -1147,7 +1216,7 @@ class PolarH10BleManager( } } - private fun handleNoContact(measurement: PolarHrMeasurement) { + private fun handleNoContact(measurement: PolarHrMeasurement): Unit = sessionLock.withLock { val hadContact = previousContactDetected == true if (previousContactDetected != false) { previousContactDetected = false @@ -1168,9 +1237,9 @@ class PolarH10BleManager( } _isContactDetected.value = false clearCachedTelemetry() - if (_isCooldownActive.value) { - log.i { "Terminating recovery cooldown due to strap removal or critical battery" } - cancelCooldown() + if (_isCooldownActive.value && contactLossJob?.isActive != true) contactLossJob = scope.launch { + delay(CONTACT_LOSS_DEBOUNCE_MS) + if (!_isContactDetected.value) cancelCooldown() } } @@ -1179,7 +1248,7 @@ class PolarH10BleManager( * eliminating packet jitter across connection intervals. Automatically re-anchors * if gaps or drift exceed 5 seconds. */ - internal fun processRrIntervals(measurement: PolarHrMeasurement) { + internal fun processRrIntervals(measurement: PolarHrMeasurement): Unit = sessionLock.withLock { if (peripheral != null && peripheral?.state?.value !is State.Connected) { return } @@ -1196,7 +1265,10 @@ class PolarH10BleManager( ) } _isContactDetected.value = true + contactLossJob?.cancel() + contactLossJob = null lastValidPacketTimestampMs = monotonicClock() + lastHeartRateMonotonicMs = monotonicClock() _hrMeasurement.value = measurement val rrs = measurement.rrIntervalsMs @@ -1213,6 +1285,7 @@ class PolarH10BleManager( } } + if (activeSessionId != null && frozenSessionSummary == null) sessionHrs.add(measurement.hr) if (rrs.isEmpty()) return val previousBeat = lastBeatTimestampMs val previousBeatMono = lastBeatMonotonicMs @@ -1237,8 +1310,9 @@ class PolarH10BleManager( val currentSid = activeSessionId if (currentSid != null) { val currentRmssd = sessionLock.withLock { - sessionHrs.add(beat.hr) - sessionRrs.add(beat.rrIntervalMs) + if (frozenSessionSummary == null) { + sessionRrs.add(beat.rrIntervalMs) + } recentRrs.addLast(beat.rrIntervalMs) if (recentRrs.size > 30) recentRrs.removeFirst() PolarTelemetryUtils.calculateRmssdMs(recentRrs.toList()) @@ -1282,20 +1356,20 @@ class PolarH10BleManager( log.i { "PMD Control Point (FB005C81) subscribed" } cpSubscribed.complete(Unit) }) - .catch { e -> - e.rethrowIfCancellation() - log.w { "PMD CP observe error: ${e.message}" } - } .collect { cpData -> log.d { "PMD CP response: ${cpData.size} bytes: ${cpData.joinToString { (it.toInt() and 0xFF).toString(16) }}" } pmdCpResponses.tryEmit(cpData) } } catch (e: Exception) { - e.rethrowIfCancellation() - log.e(e) { "PMD CP observer failed: ${e.message}" } 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"), + ) } } @@ -1306,32 +1380,42 @@ class PolarH10BleManager( log.i { "PMD Data (FB005C82) subscribed" } dataSubscribed.complete(Unit) }) - .catch { e -> - e.rethrowIfCancellation() - log.w { "PMD Data observe error: ${e.message}" } - } .collect { data -> if (p.state.value !is State.Connected) return@collect val measurement = parsePolarPmdData(data) ?: return@collect processPmdMeasurement(measurement) } } catch (e: Exception) { - e.rethrowIfCancellation() - log.e(e) { "PMD Data observer failed: ${e.message}" } 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"), + ) } } - withTimeout(BleConstants.GATT_OPERATION_TIMEOUT_MS) { - cpSubscribed.await() - dataSubscribed.await() + 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) { + internal fun processPmdMeasurement(measurement: PolarPmdMeasurement): Unit = sessionLock.withLock { if (peripheral != null && peripheral?.state?.value !is State.Connected) { return } @@ -1364,16 +1448,20 @@ class PolarH10BleManager( _latestEcg.value = measurement _ecgStream.tryEmit(measurement) val currentSid = activeSessionId - if (currentSid != null) { + if (currentSid != null && _isRawEcgStorageEnabled.value) { val nowMs = wallClock() - val csv = measurement.samplesMicrovolts.joinToString(",") 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 = csv, + samplesUvCsv = retained.joinToString(","), ) ) } @@ -1397,7 +1485,7 @@ class PolarH10BleManager( _torsoPitchDeg.value = bio.pitchDeg _torsoRollDeg.value = bio.rollDeg _dynamicAccMg.value = bio.dynamicAccMg - if (activeSessionId != null) { + if (activeSessionId != null && frozenSessionSummary == null) { sessionLock.withLock { if (maxTorsoPitch == null) { maxTorsoPitch = bio.pitchDeg @@ -1431,7 +1519,7 @@ class PolarH10BleManager( delay(50L) val responseDeferred = CompletableDeferred() - val responseListener = scope.launch { + val responseListener = scope.launch(start = CoroutineStart.UNDISPATCHED) { pmdCpResponses .filter { resp -> resp.size >= 4 && @@ -1454,7 +1542,7 @@ class PolarH10BleManager( ) val status = withTimeoutOrNull(timeoutMs) { responseDeferred.await() - } ?: 0 // If timeout waiting for indication, assume write succeeded + } ?: throw IllegalStateException("Polar PMD control point response timed out") if (status == 0) { logRepo.debug( eventType = LogEventType.COMMAND_RESPONSE, @@ -1506,7 +1594,7 @@ class PolarH10BleManager( measurementType = 0x00, ).getOrThrow() if (status != 0) { - log.w { "Polar H10 returned non-zero status $status starting ECG stream" } + return Result.failure(IllegalStateException("Polar H10 rejected ECG start: status $status")) } _isEcgStreaming.value = true @@ -1563,7 +1651,7 @@ class PolarH10BleManager( 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() @@ -1666,7 +1754,7 @@ class PolarH10BleManager( 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() @@ -1693,27 +1781,23 @@ class PolarH10BleManager( * Safe to invoke from [DisposableEffect.onDispose]. */ fun stopTelemetryStreamsAsync() { - scope.launch { - try { - stopEcgStream() - } catch (e: Exception) { - e.rethrowIfCancellation() - log.w(e) { "Failed to stop ECG stream asynchronously: ${e.message}" } - } - try { - stopAccStream() - } catch (e: Exception) { - e.rethrowIfCancellation() - log.w(e) { "Failed to stop ACC stream asynchronously: ${e.message}" } - } - } + 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()) { + 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." } @@ -1735,9 +1819,19 @@ class PolarH10BleManager( watchdogJob?.cancel() lastValidPacketTimestampMs = monotonicClock() watchdogJob = scope.launch { + var lastStreamRetryMs = monotonicClock() while (isActive && _isConnected.value) { delay(500L) - checkPacketAbsenceTimeout(monotonicClock()) + 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() } + } } } } @@ -1784,6 +1878,8 @@ class PolarH10BleManager( } private fun cleanupSessionTelemetry() { + contactLossJob?.cancel() + contactLossJob = null hrObserveJob?.cancel() hrObserveJob = null batteryObserveJob?.cancel() @@ -1802,14 +1898,14 @@ class PolarH10BleManager( _isConnected.value = false _batteryLevel.value = null - clearCachedTelemetry() + clearCachedTelemetry(resetHeartRateClock = true) if (_isCooldownActive.value) { cancelCooldown() } } - internal fun clearCachedTelemetry() { + internal fun clearCachedTelemetry(resetHeartRateClock: Boolean = false): Unit = sessionLock.withLock { if (previousContactDetected == true) { previousContactDetected = false logRepo.debug( @@ -1821,6 +1917,7 @@ class PolarH10BleManager( _isContactDetected.value = false lastBeatTimestampMs = null lastBeatMonotonicMs = null + if (resetHeartRateClock) lastHeartRateMonotonicMs = null _hrMeasurement.value = null _latestEcg.value = null _latestAcc.value = null @@ -1887,14 +1984,7 @@ class PolarH10BleManager( } internal fun isPolarH10Advertisement(adv: Advertisement): Boolean { - val name = adv.name - if (name != null && name.startsWith("Polar H10", ignoreCase = true)) { - return true - } - val hrUuidPrefix = "0000180d" - return adv.uuids.any { uuid -> - val s = uuid.toString().lowercase() - s.startsWith(hrUuidPrefix) || s == BleConstants.Polar.HR_SERVICE_UUID_STRING - } + 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 index 26cc2cb30..bec2131ca 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParser.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParser.kt @@ -134,7 +134,7 @@ fun parsePolarBatteryLevel(data: ByteArray): Int? { * * Supports: * - ECG (type 0x00, uncompressed 24-bit signed LE samples in microvolts) - * - ACC (type 0x02, uncompressed 16-bit signed LE triplets or delta-compressed bit-packed frames) + * - 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. @@ -175,52 +175,55 @@ private fun parseEcgFrame(data: ByteArray, timestampNs: Long): PolarPmdMeasureme private fun parseAccFrame(data: ByteArray, timestampNs: Long, frameType: Int): PolarPmdMeasurement.Acc? { val isCompressed = (frameType and 0x80) != 0 return if (!isCompressed) { - // Uncompressed 16-bit signed LE samples (6 bytes per sample) - frameType 0x00, 0x01 + val axisByteCount = when (frameType) { + 0x00 -> 1 + 0x01 -> 2 + else -> return null + } val samples = mutableListOf() var offset = 10 - while (offset + 6 <= data.size) { - val x = getInt16LE(data, offset) - val y = getInt16LE(data, offset + 2) - val z = getInt16LE(data, offset + 4) + 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 += 6 + 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 < 18) return null + if (data.size < 16) return null var currentX = getInt16LE(data, 10) var currentY = getInt16LE(data, 12) var currentZ = getInt16LE(data, 14) - val deltaSizeBits = data[16].toInt() and 0xFF - val sampleCount = data[17].toInt() and 0xFF - val samples = mutableListOf() // The baseline sample at index 10..15 is the initial reference sample samples.add(PolarAccSample(currentX, currentY, currentZ)) - if (deltaSizeBits == 0 || sampleCount == 0 || deltaSizeBits > 16) { - return PolarPmdMeasurement.Acc(timestampNs, samples) - } - - val totalAvailableBits = (data.size - 18) * 8 - var bitPos = 0 - for (i in 0 until sampleCount) { - if (bitPos + (deltaSizeBits * 3) > totalAvailableBits) break - val deltaX = readSignedBits(data, 18, bitPos, deltaSizeBits) - bitPos += deltaSizeBits - val deltaY = readSignedBits(data, 18, bitPos, deltaSizeBits) - bitPos += deltaSizeBits - val deltaZ = readSignedBits(data, 18, bitPos, deltaSizeBits) - bitPos += deltaSizeBits - - currentX += deltaX - currentY += deltaY - currentZ += deltaZ - 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( 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/SqlDelightWorkoutRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt index e0aa02fd6..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 @@ -3,14 +3,14 @@ package com.devil.phoenixproject.data.repository import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToList import co.touchlab.kermit.Logger -import com.devil.phoenixproject.database.MetricSample import com.devil.phoenixproject.database.PhoenixDatabase -import com.devil.phoenixproject.database.PolarBeatSample -import com.devil.phoenixproject.database.PolarEcgSegment 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 @@ -22,8 +22,6 @@ import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.domain.model.currentTimeMillis import com.devil.phoenixproject.domain.model.generateUUID import com.devil.phoenixproject.domain.onerepmax.WorkoutVelocityPoint -import com.devil.phoenixproject.util.PolarTelemetryUtils -import kotlin.math.abs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow @@ -1694,17 +1692,46 @@ class SqlDelightWorkoutRepository( override suspend fun getPolarBeatSamplesBySession(sessionId: String): List = withContext(Dispatchers.IO) { - queries.selectPolarBeatSamplesBySession(sessionId).executeAsList() + 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() + 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 = + override suspend fun getMetricSamplesBySession(sessionId: String): List = withContext(Dispatchers.IO) { - queries.selectMetricsBySession(sessionId).executeAsList() + queries.selectMetricsBySession(sessionId).executeAsList().map { row -> + PolarMetricSample( + position = row.position, + positionB = row.positionB, + torsoPitchDeg = row.torsoPitchDeg, + torsoRollDeg = row.torsoRollDeg, + ) + } } } 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 6e4c500e0..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 @@ -1,10 +1,10 @@ package com.devil.phoenixproject.data.repository -import com.devil.phoenixproject.database.MetricSample -import com.devil.phoenixproject.database.PolarBeatSample -import com.devil.phoenixproject.database.PolarEcgSegment 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 @@ -256,8 +256,8 @@ interface WorkoutRepository { /** Historical Polar ECG segments for a session. */ suspend fun getPolarEcgSegmentsBySession(sessionId: String): List - /** Historical raw metric samples for a session. */ - suspend fun getMetricSamplesBySession(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 d8be81fce..b5f02c7ed 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt @@ -56,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() } @@ -172,5 +173,5 @@ val dataModule = module { single { HealthBackfillManager(get(), get(), get(), get()) } // Polar H10 BLE Manager - single { PolarH10BleManager(settings = get(), database = get()) } onClose { (it as? PolarH10BleManager)?.shutdown() } + 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 1c46e24c7..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,7 +419,7 @@ 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 58) + // Polar H10 intra-set synchronized telemetry & torso biomechanics (migration 59) val heartRateBpm: Int? = null, val rrIntervalMs: Float? = null, val torsoPitchDeg: Float? = null, @@ -629,7 +629,7 @@ 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 58) + // Polar H10 cardiac & torso biomechanics summary (added in migration 59) val avgHeartRateBpm: Int? = null, val maxHeartRateBpm: Int? = null, val minHeartRateBpm: Int? = null, 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 19d8e0249..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 @@ -9304,7 +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?.getSessionSummary(lease.sessionId) + val polarSummary = polarBleManager?.freezeSessionRecording(lease.sessionId) val session = WorkoutSession( id = lease.sessionId, timestamp = timing.startMs, @@ -9571,7 +9571,7 @@ class ActiveSessionEngine( repMetrics = snapshot.repMetrics, repBiomechanics = snapshot.biomechanicsRepResults, ) - polarBleManager?.flushSessionTelemetry(sessionId) + polarBleManager?.flushSessionTelemetry(sessionId)?.join() // Issue #869: the set is durable; Set Summary may now appear. commitSettled?.complete(Unit) snapshot.singleExerciseDefaults?.let { defaults -> @@ -10628,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) { @@ -10818,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, @@ -10860,7 +10877,9 @@ class ActiveSessionEngine( repMetrics = emptyList(), repBiomechanics = emptyList(), ) - polarBleManager?.flushSessionTelemetry(session.id) + 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 96a77d385..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 @@ -533,9 +533,7 @@ class DefaultWorkoutSessionManager( } is WorkoutState.Completed, WorkoutState.RoutineComplete -> { if (polarBleManager.isConnected.value) { - polarBleManager.endSessionRecording( - polarBleManager.lastRecordedSessionId.value ?: coordinator.currentSessionId, - ) + polarBleManager.endSessionRecording() } } is WorkoutState.SetSummary -> { @@ -561,9 +559,7 @@ class DefaultWorkoutSessionManager( coordinator.routineFlowState.collect { flowState -> if (flowState is RoutineFlowState.Complete) { if (polarBleManager.isConnected.value) { - polarBleManager.endSessionRecording( - polarBleManager.lastRecordedSessionId.value ?: coordinator.currentSessionId, - ) + polarBleManager.endSessionRecording() } } } 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 aa3297516..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 @@ -836,7 +836,6 @@ class MainViewModel( val scannedDevices: StateFlow> get() = bleConnectionManager.scannedDevices val connectionError: StateFlow get() = bleConnectionManager.connectionError val connectionLostDuringWorkout: StateFlow get() = bleConnectionManager.connectionLostDuringWorkout - val isPolarConnected: StateFlow? get() = polarBleManager?.isConnected val machineSafetyUiState: StateFlow = machineSafetyCoordinator.uiState fun startScanning() = bleConnectionManager.startScanning() 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 5ce629d8b..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,7 +94,7 @@ 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 58) + // Polar H10 cardiac & torso biomechanics summary (added in migration 59) val avgHeartRateBpm: Int? = null, val maxHeartRateBpm: Int? = null, val minHeartRateBpm: Int? = null, @@ -122,7 +122,7 @@ 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 58) + // Polar H10 intra-set synchronized telemetry & torso biomechanics (added in migration 59) val heartRateBpm: Int? = null, val rrIntervalMs: Float? = null, val torsoPitchDeg: Float? = null, 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 9f6b4c1b8..c80be6205 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt @@ -2619,6 +2619,16 @@ abstract class BaseDataBackupManager( 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, @@ -2661,6 +2671,16 @@ abstract class BaseDataBackupManager( 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, 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 6a4b7123c..c7cfd9601 100644 --- a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/PhoenixDatabase.sq +++ b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/PhoenixDatabase.sq @@ -1247,6 +1247,12 @@ UPDATE WorkoutSession SET 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, @@ -1256,6 +1262,9 @@ INSERT INTO PolarBeatSample ( selectPolarBeatSamplesBySession: SELECT * FROM PolarBeatSample WHERE sessionId = ? ORDER BY timestampMs ASC; +selectPolarBeatSampleById: +SELECT * FROM PolarBeatSample WHERE id = ?; + deletePolarBeatSamplesBySession: DELETE FROM PolarBeatSample WHERE sessionId = ?; @@ -1279,6 +1288,19 @@ INSERT INTO PolarEcgSegment ( 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 = ?; 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 index c3e9fb909..23bdc6c39 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManagerTest.kt @@ -1,6 +1,8 @@ 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 @@ -9,6 +11,9 @@ 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 @@ -18,17 +23,23 @@ 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 { @@ -49,11 +60,13 @@ class PolarH10BleManagerTest { } @Test - fun `isPolarH10Advertisement matches standard Heart Rate service UUID 0x180D`() { + fun `standard Heart Rate UUID alone never identifies an H10`() { val manager = PolarH10BleManager() val hrUuid = BleConstants.Polar.HR_SERVICE_UUID - assertTrue(manager.isPolarH10Advertisement(CompositionAdvertisement(name = null, uuids = listOf(hrUuid)))) + 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)))) @@ -294,8 +307,11 @@ class PolarH10BleManagerTest { assertFalse(manager.isCooldownActive.value, "Resuming routine with added exercise must cancel cooldown") assertEquals(0, manager.cooldownSecondsRemaining.value) - // 6. Routine completes again -> cooldown starts again - manager.endSessionRecording("routine-session-1") + // 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) @@ -415,9 +431,9 @@ class PolarH10BleManagerTest { } @Test - fun `strap removal or disconnection during cooldown immediately terminates recovery window`() { + fun `strap removal is debounced while disconnect immediately terminates recovery`() = runTest { var simulatedTime = 100_000L - val testScope = CoroutineScope(Dispatchers.Unconfined + Job()) + val testScope = backgroundScope val manager = PolarH10BleManager(scope = testScope, timeSource = { simulatedTime }) manager.startSessionRecording("session-recovery", "WORKING_SET") @@ -430,7 +446,10 @@ class PolarH10BleManagerTest { // User unhooks strap during recovery cooldown manager.processRrIntervals(PolarHrMeasurement(hr = 0, contactDetected = false, rrIntervalsMs = emptyList())) - assertFalse(manager.isCooldownActive.value, "Cooldown must be terminated upon strap removal") + 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 @@ -687,7 +706,7 @@ class PolarH10BleManagerTest { // Once cooldown ended, summary queries reject, arbitrary sessions reject cooldown, // but the just-finished session-A can explicitly restart cooldown via forceRestart - assertNull(manager.getSessionSummary("session-A")) + assertEquals(140, manager.getSessionSummary("session-A")?.avgHeartRateBpm) manager.triggerCooldown("session-B", forceRestart = true) assertFalse(manager.isCooldownActive.value) manager.triggerCooldown("session-A", forceRestart = true) @@ -822,7 +841,7 @@ class PolarH10BleManagerTest { } @Test - fun `startSessionRecording resets isExplicitDisconnect and auto-reconnects to remembered sensor`() { + fun `startSessionRecording preserves explicit disconnect suppression`() { val fakeAd = CompositionAdvertisement( id = "00000000-0000-0000-0000-24acac0e5b59", name = "Polar H10 12345678", @@ -850,10 +869,10 @@ class PolarH10BleManagerTest { // through connect() without self-cancelling reconnectJob manager.startSessionRecording("session-123", "WORKING_SET") - assertFalse(manager.isExplicitDisconnectForTesting) - assertTrue(manager.isConnected.value) + assertTrue(manager.isExplicitDisconnectForTesting) + assertFalse(manager.isConnected.value) assertEquals( - ConnectionState.Connected("Polar H10 12345678", rememberedId), + ConnectionState.Disconnected, manager.connectionState.value, ) @@ -925,9 +944,10 @@ class PolarH10BleManagerTest { var simulatedTime = 100_000L val database = com.devil.phoenixproject.testutil.createTestDatabase() val manager = PolarH10BleManager( - database = database, + telemetryRepository = SqlDelightPolarTelemetryRepository(database, { simulatedTime }, Dispatchers.Unconfined), scope = this, timeSource = { simulatedTime }, + persistenceDispatcher = Dispatchers.Unconfined, ) manager.setConnectedForTesting(true) @@ -992,6 +1012,255 @@ class PolarH10BleManagerTest { 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 index a7b91ec31..d8ef05605 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParserTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParserTest.kt @@ -233,13 +233,13 @@ class PolarProtocolParserTest { @Test fun `parsePolarPmdData parses uncompressed 16-bit ACC samples`() { - // Type 0x02 (ACC), Timestamp: 5000 ns, FrameType: 0x00 + // 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] = 0x00 // uncompressed + this[9] = 0x01 // X = 100 (0x0064) this[10] = 0x64 @@ -329,6 +329,61 @@ class PolarProtocolParserTest { 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) @@ -399,4 +454,12 @@ class PolarProtocolParserTest { 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/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 5e44ce9c1..8d6102140 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt @@ -1,9 +1,6 @@ package com.devil.phoenixproject.testutil import com.devil.phoenixproject.data.repository.CompletedSetRepository -import com.devil.phoenixproject.database.MetricSample -import com.devil.phoenixproject.database.PolarBeatSample -import com.devil.phoenixproject.database.PolarEcgSegment import com.devil.phoenixproject.data.repository.MAX_RECENT_EXERCISE_SESSIONS import com.devil.phoenixproject.data.repository.RepMetricRepository import com.devil.phoenixproject.data.repository.RoutineCsvImportConflictException @@ -11,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 @@ -405,7 +405,7 @@ class FakeWorkoutRepository : WorkoutRepository { override suspend fun getPolarEcgSegmentsBySession(sessionId: String): List = polarEcgSegmentsBySession(sessionId) - var metricSamplesBySession: (String) -> List = { emptyList() } - override suspend fun getMetricSamplesBySession(sessionId: String): List = + var metricSamplesBySession: (String) -> List = { emptyList() } + override suspend fun getMetricSamplesBySession(sessionId: String): List = metricSamplesBySession(sessionId) } 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 index 5556b7c12..f8e8be3fd 100644 --- a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10SettingsCard.kt @@ -1,6 +1,5 @@ package com.devil.phoenixproject.presentation.components -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -29,30 +28,19 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -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.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.ui.draw.clip 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.ui.theme.Spacing -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.compose.koinInject import kotlin.math.roundToInt @@ -68,33 +56,15 @@ fun PolarH10SettingsCard( val batteryLevel by polarBleManager.batteryLevel.collectAsState() val hrMeasurement by polarBleManager.hrMeasurement.collectAsState() - val latestEcg by polarBleManager.latestEcg.collectAsState() - val latestAcc by polarBleManager.latestAcc.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() - val isAccStreaming by polarBleManager.isAccStreaming.collectAsState() - val isEcgStreaming by polarBleManager.isEcgStreaming.collectAsState() - - val ecgSamples = remember { mutableStateListOf() } - LaunchedEffect(isEcgStreaming) { - if (!isEcgStreaming) { - ecgSamples.clear() - } else { - polarBleManager.ecgStream.collect { ecg -> - ecgSamples.addAll(ecg.samplesMicrovolts) - while (ecgSamples.size > 260) { - ecgSamples.removeAt(0) - } - } - } - } @@ -177,6 +147,25 @@ fun PolarH10SettingsCard( 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 -> { @@ -413,93 +402,6 @@ fun PolarH10SettingsCard( ) } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - "50 Hz ACC Stream", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - ) - Text( - "Stream live chest accelerometer & posture", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = isAccStreaming, - onCheckedChange = { enable -> - scope.launch { - if (enable) polarBleManager.startAccStream() else polarBleManager.stopAccStream() - } - }, - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - "130 Hz ECG Stream", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - ) - Text( - "Stream live single-lead electrocardiogram trace", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = isEcgStreaming, - onCheckedChange = { enable -> - scope.launch { - if (enable) polarBleManager.startEcgStream() else polarBleManager.stopEcgStream() - } - }, - ) - } - - AnimatedVisibility(visible = isEcgStreaming) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(72.dp) - .clip(MaterialTheme.shapes.small) - .background(Color(0xFF0D1B2A)) - .border(1.dp, Color(0xFF1E3A5F), MaterialTheme.shapes.small), - contentAlignment = Alignment.Center, - ) { - if (ecgSamples.isEmpty()) { - Text( - text = if (isContactDetected) "Waiting for ECG signal..." else "Ensure strap electrodes are moist and snug against skin", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 11.sp, - ) - } - EcgWaveformCanvas( - samples = ecgSamples, - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .padding(horizontal = 4.dp, vertical = 4.dp), - ) - } - } - } } // Torso Biomechanics Posture Section 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 index 87702072c..4f6d90884 100644 --- a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarH10WorkoutStrip.kt @@ -121,15 +121,9 @@ private fun PolarH10WorkoutStripContent( val torsoRollDeg by polarBleManager.torsoRollDeg.collectAsState() val dynamicAccMg by polarBleManager.dynamicAccMg.collectAsState() - // 1. Auto-start ECG & ACC on enter, cleanly stop on dispose - LaunchedEffect(Unit) { - polarBleManager.startEcgStream() - polarBleManager.startAccStream() - } - DisposableEffect(Unit) { onDispose { - polarBleManager.stopTelemetryStreamsAsync() + polarBleManager.releaseTelemetryPreviewAsync() } } 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 index 01194b760..91f33f30a 100644 --- a/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt +++ b/shared/src/phoneMain/kotlin/com/devil/phoenixproject/presentation/components/PolarSessionAnalyticsCard.kt @@ -36,10 +36,9 @@ 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.util.PolarTelemetryUtils -import com.devil.phoenixproject.database.MetricSample -import com.devil.phoenixproject.database.PolarBeatSample -import com.devil.phoenixproject.database.PolarEcgSegment +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 @@ -69,33 +68,28 @@ fun PolarSessionAnalyticsCard( workoutRepository: WorkoutRepository? = null, ) { val connectionState = polarBleManager?.connectionState?.collectAsState()?.value ?: ConnectionState.Disconnected - val isCooldownActive = polarBleManager?.isCooldownActive?.collectAsState()?.value ?: false + val managerCooldownActive = polarBleManager?.isCooldownActive?.collectAsState()?.value ?: false val cooldownSecondsRemaining = polarBleManager?.cooldownSecondsRemaining?.collectAsState()?.value ?: 0 - val cooldownHrr60 = polarBleManager?.cooldownHrr60?.collectAsState()?.value - val cooldownHrr300 = polarBleManager?.cooldownHrr300?.collectAsState()?.value 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 = if (showCooldown) { + val resolvedSessionId = sessionId ?: session?.id ?: if (showCooldown) { activeCooldownSessionId ?: lastRecordedSessionId ?: sessionId ?: session?.id ?: polarBleManager?.currentRecordingSessionId ?: latestSummary?.sessionId - } else { - sessionId - ?: session?.id - ?: activeCooldownSessionId - ?: polarBleManager?.currentRecordingSessionId - ?: latestSummary?.sessionId - ?: lastRecordedSessionId - } + } 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?.let { s -> + 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) { @@ -115,41 +109,45 @@ fun PolarSessionAnalyticsCard( } ?: resolvedSessionId?.let { polarBleManager?.getSessionSummary(it) } } - val rawSummary = if (resolvedSessionId != null && latestSummary?.sessionId == resolvedSessionId) { + var persistedSummary by remember(resolvedSessionId) { mutableStateOf(null) } + val summary = if (resolvedSessionId != null && latestSummary?.sessionId == resolvedSessionId) { latestSummary } else { - fallbackSummary + 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()) } + var metricSamples by remember(resolvedSessionId) { mutableStateOf>(emptyList()) } - val computedRmssdFromBeats = remember(beatSamples) { - if (beatSamples.size >= 2) { - PolarTelemetryUtils.calculateRmssdMs(beatSamples.map { it.rrMs })?.toFloat() - } else null - } - - val computedAvgRrFromBeats = remember(beatSamples) { - val validRrs = beatSamples.map { it.rrMs }.filter { it in 250.0..2500.0 } - if (validRrs.isNotEmpty()) validRrs.average().toFloat() else null - } - - val summary = remember(rawSummary, computedRmssdFromBeats, computedAvgRrFromBeats) { - if (rawSummary == null && computedRmssdFromBeats == null && computedAvgRrFromBeats == null) null - else { - val base = rawSummary ?: PolarSessionSummary() - base.copy( - rmssdMs = computedRmssdFromBeats ?: rawSummary?.rmssdMs, - avgRrIntervalMs = computedAvgRrFromBeats ?: rawSummary?.avgRrIntervalMs, - ) + 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, + ) + } } } - LaunchedEffect(resolvedSessionId, workoutRepository) { - if (resolvedSessionId != null && workoutRepository != null) { + 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) { @@ -168,9 +166,7 @@ fun PolarSessionAnalyticsCard( } } - val isConnected = connectionState is ConnectionState.Connected - val hasCardiacData = summary?.avgHeartRateBpm != null || summary?.maxHeartRateBpm != null || isCooldownActive || beatSamples.isNotEmpty() || ecgSegments.isNotEmpty() - val hasBiomechanicsData = summary?.maxTorsoPitchDeg != null || summary?.maxTorsoRollDeg != null || metricSamples.any { it.torsoPitchDeg != null || it.torsoRollDeg != null } + 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) { @@ -322,7 +318,7 @@ fun PolarSessionAnalyticsCard( label = "HRV (RMSSD)", value = "${rmssd.roundToInt()}", unit = "ms", - subtext = if (rmssd >= 50f) "High Vagal Tone" else if (rmssd >= 25f) "Moderate" else "High Strain", + subtext = "Successive R-R variation", icon = Icons.Default.Speed, tint = Color(0xFF4CAF50), modifier = Modifier.weight(1f), @@ -374,7 +370,7 @@ fun PolarSessionAnalyticsCard( // Section 4: 5-Minute Heart Rate Recovery (HRR) Protocol Card val hasRecoveryData = summary?.hrr60Bpm != null || summary?.hrr300Bpm != null || isCooldownActive - if (showCooldown && (hasRecoveryData || isConnected)) { + if (hasRecoveryData || isConnected) { Card( modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, @@ -465,8 +461,7 @@ fun PolarSessionAnalyticsCard( } dropSoFar?.let { drop -> - val isWarning = drop < 12 && cooldownSecondsRemaining <= 240 - val tint = if (isWarning) Color(0xFFFF9800) else Color(0xFF4CAF50) + val tint = MaterialTheme.colorScheme.primary Surface( shape = MaterialTheme.shapes.small, color = tint.copy(alpha = 0.15f), @@ -511,8 +506,8 @@ fun PolarSessionAnalyticsCard( } } else { // Historical or Post-Cooldown State - val hrr60 = summary?.hrr60Bpm ?: cooldownHrr60 - val hrr300 = summary?.hrr300Bpm ?: cooldownHrr300 + val hrr60 = summary?.hrr60Bpm + val hrr300 = summary?.hrr300Bpm if (hrr60 != null || hrr300 != null) { Row( @@ -523,12 +518,7 @@ fun PolarSessionAnalyticsCard( RecoveryChip( label = "1-Min Recovery (HRR-60)", drop = drop60, - rating = when { - drop60 >= 25 -> "Elite" - drop60 >= 18 -> "Good" - drop60 >= 12 -> "Fair" - else -> "Elevated Risk" - }, + rating = "Change after 60 seconds", modifier = Modifier.weight(1f), ) } @@ -536,11 +526,7 @@ fun PolarSessionAnalyticsCard( RecoveryChip( label = "5-Min Recovery (HRR-300)", drop = drop300, - rating = when { - drop300 >= 50 -> "Excellent" - drop300 >= 35 -> "Optimal" - else -> "Slow" - }, + rating = "Change after 300 seconds", modifier = Modifier.weight(1f), ) } @@ -553,7 +539,7 @@ fun PolarSessionAnalyticsCard( TextButton( onClick = { polarBleManager?.triggerCooldown( - polarBleManager.lastRecordedSessionId.value, + resolvedSessionId, forceRestart = true, ) }, @@ -579,7 +565,7 @@ fun PolarSessionAnalyticsCard( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Track vagal tone reactivation & recovery slope.", + text = "Track heart-rate change after your workout.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f).padding(end = 8.dp), @@ -587,7 +573,7 @@ fun PolarSessionAnalyticsCard( FilledTonalButton( onClick = { polarBleManager?.triggerCooldown( - polarBleManager.lastRecordedSessionId.value, + resolvedSessionId, forceRestart = true, ) }, @@ -706,8 +692,7 @@ private fun RecoveryChip( rating: String, modifier: Modifier = Modifier, ) { - val isWarning = drop < 12 - val tint = if (isWarning) Color(0xFFFF9800) else Color(0xFF4CAF50) + val tint = MaterialTheme.colorScheme.primary Surface( modifier = modifier, shape = MaterialTheme.shapes.small, @@ -754,7 +739,7 @@ private fun ContinuousHeartRateTimelineChart( modifier: Modifier = Modifier, ) { val validSamples = remember(samples) { - samples.filter { it.hrBpm != null && it.hrBpm > 0 } + samples.filter { it.hrBpm > 0 } } if (validSamples.size < 2) return @@ -762,8 +747,8 @@ private fun ContinuousHeartRateTimelineChart( 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 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) } @@ -989,7 +974,7 @@ private fun ContinuousHeartRateTimelineChart( val hrFillPath = Path() var hrStarted = false for (s in validSamples) { - val hr = s.hrBpm!!.toInt() + val hr = s.hrBpm.toInt() val x = ((s.timestampMs - minTs).toFloat() / durationMs) * w val y = h - ((hr - minHr) / hrRange) * h if (!hrStarted) { @@ -1048,12 +1033,12 @@ private fun ContinuousHeartRateTimelineChart( } /** - * Chart 2 — 3D Torso Biomechanics vs. Rep Position (`MetricSample`). + * 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, + samples: List, modifier: Modifier = Modifier, ) { val biomechSamples = remember(samples) { 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 4e340d8e4..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 @@ -198,10 +198,6 @@ fun ActiveWorkoutScreen( } } - LaunchedEffect(Unit) { - polarBleManager.ensureConnectedOrReconnect() - } - LaunchedEffect(Unit) { viewModel.userFeedbackEvents.collect { message -> snackbarScope.launch { 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 ffd0a490a..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 @@ -1202,12 +1202,28 @@ private fun GroupedRoutineCard( } // Polar H10 Cardiac & Biomechanics Telemetry Section (rendered below all exercises & rep details) - val polarSession = groupedItem.sessions.find { - it.avgHeartRateBpm != null || it.maxHeartRateBpm != null || it.hrr60Bpm != null - } + 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, 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 815a803af..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 @@ -289,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) From 2b112607957c0d11be2cb564a56eb4cea25695e3 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 29 Oct 2026 17:46:54 -0400 Subject: [PATCH 23/25] fix(polar): import IO dispatcher for native targets --- .../com/devil/phoenixproject/data/ble/PolarH10BleManager.kt | 1 + 1 file changed, 1 insertion(+) 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 index b4dabc5b1..1e4b06840 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/PolarH10BleManager.kt @@ -33,6 +33,7 @@ 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 From 9dd0dc8ad885f365091f1fd7b10fbb2f55d393a5 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 29 Oct 2026 18:00:58 -0400 Subject: [PATCH 24/25] test: make deferred PR weight conversion deterministic --- .../viewmodel/ExerciseConfigViewModelTest.kt | 67 +++++++++++-------- 1 file changed, 38 insertions(+), 29 deletions(-) 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 From a29fb71745a9ae9ca89f0bf5aee63f3439754f98 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 29 Oct 2026 18:10:55 -0400 Subject: [PATCH 25/25] test(polar): use a native-compatible parser test name --- .../devil/phoenixproject/data/ble/PolarProtocolParserTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index d8ef05605..e2d382af4 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParserTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/PolarProtocolParserTest.kt @@ -111,7 +111,7 @@ class PolarProtocolParserTest { } @Test - fun `parsePolarHrMeasurement parses complete packet with 16-bit HR, energy, and multiple RR intervals`() { + 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])