diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/MonitorDataProcessor.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/MonitorDataProcessor.kt index 00e6d2606..68db2bb27 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/MonitorDataProcessor.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/MonitorDataProcessor.kt @@ -1,12 +1,14 @@ package com.devil.phoenixproject.data.ble import co.touchlab.kermit.Logger +import com.devil.phoenixproject.domain.model.MachineStatusEvent import com.devil.phoenixproject.domain.model.SampleStatus import com.devil.phoenixproject.domain.model.WorkoutMetric import com.devil.phoenixproject.domain.model.currentTimeMillis import com.devil.phoenixproject.util.BleConstants import com.devil.phoenixproject.util.Constants import kotlin.math.abs +import kotlin.math.max /** * Synchronous processing pipeline for BLE monitor packets. @@ -42,6 +44,7 @@ import kotlin.math.abs class MonitorDataProcessor( private val onDeloadOccurred: () -> Unit = {}, private val onRomViolation: (RomViolationType) -> Unit = {}, + private val onStatusEvent: (MachineStatusEvent) -> Unit = {}, private val timeProvider: () -> Long = { currentTimeMillis() }, ) { private val log = Logger.withTag("MonitorDataProcessor") @@ -220,6 +223,20 @@ class MonitorDataProcessor( // Update timestamp for poll rate diagnostics lastTimestamp = currentTime + // ===== STAGE 6B: STATUS EVENT EMISSION ===== + // Issue #673 PR 2: emit MachineStatusEvent carrying the full SampleStatus + + // position + velocity for downstream ROM-fraction stall detection. + // Fires on EVERY processed packet (including status=0) so the ROM-fraction + // collector gets continuous position/velocity data, not just edge events. + onStatusEvent( + MachineStatusEvent( + timestamp = currentTime, + sampleStatus = SampleStatus(packet.status), + position = max(posA, posB), + velocity = max(abs(smoothedVelocityA), abs(smoothedVelocityB)).toFloat(), + ), + ) + // ===== STAGE 7: BUILD METRIC ===== return WorkoutMetric( timestamp = currentTime, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/BleRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/BleRepository.kt index 08fc5e8cd..90929e362 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/BleRepository.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/BleRepository.kt @@ -2,6 +2,7 @@ package com.devil.phoenixproject.data.repository import com.devil.phoenixproject.data.ble.DiagnosticPacket import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.domain.model.MachineStatusEvent import com.devil.phoenixproject.domain.model.WorkoutMetric import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -142,6 +143,9 @@ interface BleRepository { // Deload safety event (for Just Lift mode safety recovery) val deloadOccurredEvents: Flow + // Full machine status-word events (Issue #673 PR 2: ROM-fraction stall detection) + val machineStatusEvents: Flow + // Reconnection request (for auto-recovery on connection loss) val reconnectionRequested: Flow diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/KableBleRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/KableBleRepository.kt index b3971b1bb..3411c4c53 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/KableBleRepository.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/KableBleRepository.kt @@ -18,6 +18,7 @@ import com.devil.phoenixproject.data.ble.parseRepPacket import com.devil.phoenixproject.data.ble.toVitruvianHex import com.devil.phoenixproject.domain.model.ConnectionState import com.devil.phoenixproject.domain.model.HeuristicStatistics +import com.devil.phoenixproject.domain.model.MachineStatusEvent import com.devil.phoenixproject.domain.model.WorkoutMetric import com.devil.phoenixproject.domain.model.WorkoutParameters import com.devil.phoenixproject.util.BlePacketFactory @@ -83,6 +84,12 @@ class KableBleRepository : BleRepository { onBufferOverflow = BufferOverflow.DROP_OLDEST, ) override val deloadOccurredEvents: Flow = _deloadOccurredEvents.asSharedFlow() + private val _machineStatusEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + override val machineStatusEvents: Flow = _machineStatusEvents.asSharedFlow() enum class RomViolationType { OUTSIDE_HIGH, OUTSIDE_LOW } private val _romViolationEvents = MutableSharedFlow( replay = 0, @@ -119,6 +126,9 @@ class KableBleRepository : BleRepository { } publishSafetyEvent(_romViolationEvents, mapped, BleCriticalEventType.ROM_VIOLATION) }, + onStatusEvent = { event -> + _machineStatusEvents.tryEmit(event) + }, ) private val discoMode = DiscoMode( @@ -512,6 +522,10 @@ class KableBleRepository : BleRepository { _deloadOccurredEvents.emit(Unit) } + internal suspend fun publishMachineStatusEventForTest(event: MachineStatusEvent) { + _machineStatusEvents.emit(event) + } + internal suspend fun publishRomViolationForTest(type: RomViolationType) { _romViolationEvents.emit(type) } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/MachineStatusEvent.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/MachineStatusEvent.kt new file mode 100644 index 000000000..a5071ed30 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/MachineStatusEvent.kt @@ -0,0 +1,19 @@ +package com.devil.phoenixproject.domain.model + +/** + * Carries the full machine status-word, position, and velocity from every processed BLE monitor + * sample, including packets whose status word is zero. Supersedes the narrow [Unit]-typed + * `deloadOccurredEvents` flow for downstream consumers that need richer context + * (e.g. ROM-fraction stall detection in Issue #673 PR 2). + * + * @param timestamp Epoch-ms when the sample was received + * @param sampleStatus Parsed status-word flags from the monitor packet + * @param position Cable position in mm (max of A/B at time of status sample) + * @param velocity Cable velocity in mm/s (max of A/B, EMA-smoothed, at time of status sample) + */ +data class MachineStatusEvent( + val timestamp: Long, + val sampleStatus: SampleStatus, + val position: Float, + val velocity: Float, +) 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 9f0e35058..2d82ad82a 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 @@ -74,6 +74,7 @@ import com.devil.phoenixproject.util.DataBackupManager import com.devil.phoenixproject.util.KmpUtils import com.devil.phoenixproject.util.WorkoutCommandValidator import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.abs import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart @@ -657,12 +658,16 @@ class ActiveSessionEngine( coordinator.stallStartTime = currentTimeMillis() coordinator.isCurrentlyStalled = true coordinator.stallArmedByDeload = true + coordinator.stallArmedByRomFraction = false + coordinator.romFractionStallAnchorPosition = null Logger.d("Auto-stop stall timer STARTED via DELOAD_OCCURRED flag") } else if (coordinator.stallStartTime != null && !inGrace) { // F4: a real deload is the stronger signal — upgrade a // velocity-armed countdown so the retracting cables // (position -> 0) don't cancel it via the racked-handles check. coordinator.stallArmedByDeload = true + coordinator.stallArmedByRomFraction = false + coordinator.romFractionStallAnchorPosition = null } else if (inGrace) { Logger.d("DELOAD_OCCURRED ignored - in AMRAP startup grace period") } @@ -670,6 +675,87 @@ class ActiveSessionEngine( } } + // #5b: Issue #673 PR 2: ROM-fraction stall detection collector. + // Consumes MachineStatusEvent carrying the full SampleStatus + position + velocity. + // When velocity is in the dead band (2.5–10 mm/s) AND position is mid-ROM + // (30–80%), arms the stall timer as a secondary signal. Rep events cancel + // any position-armed countdown (handled via resetStallTimer in rep processing). + scope.launch { + bleRepository.machineStatusEvents + .catch { e -> Logger.e(e) { "machineStatusEvents collector error" } } + .collect { event -> + val params = coordinator._workoutParameters.value + val currentState = coordinator._workoutState.value + + if (!params.stallDetectionEnabled || currentState !is WorkoutState.Active) return@collect + if (params.isEchoMode) return@collect + + // Track ROM range from position observations (even during warmup, + // so the detector has a calibrated range when warmup ends) + val pos = event.position + val currentTop = coordinator.romRangeTop + val currentBottom = coordinator.romRangeBottom + if (currentTop == null || pos > currentTop) coordinator.romRangeTop = pos + if (currentBottom == null || pos < currentBottom) coordinator.romRangeBottom = pos + + // Gate timer arming until warmup is complete and auto-stop is enabled. + if (!shouldEnableAutoStop(params)) return@collect + + val top = coordinator.romRangeTop ?: return@collect + val bottom = coordinator.romRangeBottom ?: return@collect + val range = top - bottom + if (range < WorkoutCoordinator.MIN_RANGE_THRESHOLD) return@collect + + val fraction = (pos - bottom) / range + + val velocity = event.velocity.toDouble() + + // Arm conditions: mid-ROM (30–80%) AND velocity in dead band (2.5–10 mm/s) + val inMidRom = fraction in 0.3f..0.8f + val inDeadBand = velocity >= WorkoutCoordinator.STALL_VELOCITY_LOW && + velocity <= WorkoutCoordinator.STALL_VELOCITY_HIGH + + if (inMidRom && inDeadBand) { + val repCount = coordinator._repCount.value + if (shouldDeferStandardSetStall(params, repCount)) return@collect + + val hasMeaningfulRange = repCounter.hasMeaningfulRange(WorkoutCoordinator.MIN_RANGE_THRESHOLD) + if (isInAmrapStartupGrace(hasMeaningfulRange)) return@collect + + if (coordinator.stallStartTime == null) { + coordinator.stallStartTime = currentTimeMillis() + coordinator.isCurrentlyStalled = true + coordinator.stallArmedByDeload = false + coordinator.stallArmedByRomFraction = true + coordinator.romFractionStallAnchorPosition = pos + Logger.d("Auto-stop stall timer STARTED via ROM-fraction signal (fraction=$fraction, velocity=$velocity)") + } else if (coordinator.stallArmedByRomFraction && !coordinator.stallArmedByDeload) { + val anchor = coordinator.romFractionStallAnchorPosition + if (anchor == null) { + coordinator.romFractionStallAnchorPosition = pos + } else if (abs(pos - anchor) >= WorkoutCoordinator.ROM_FRACTION_STALL_PROGRESS_THRESHOLD_MM) { + coordinator.stallStartTime = currentTimeMillis() + coordinator.romFractionStallAnchorPosition = pos + Logger.d( + "Auto-stop stall timer RESET via ROM-fraction progress " + + "(position=$pos, anchor=$anchor, velocity=$velocity)", + ) + } + } + } else if ( + coordinator.stallStartTime != null && + coordinator.stallArmedByRomFraction && + !coordinator.stallArmedByDeload + ) { + Logger.d( + "Auto-stop stall timer CANCELLED via ROM-fraction signal " + + "(fraction=$fraction, velocity=$velocity)", + ) + resetStallTimer() + } + } + } + // #6: Rep events collector for handling machine rep notifications coordinator.repEventsCollectionJob = scope.launch { bleRepository.repEvents @@ -1183,6 +1269,8 @@ class ActiveSessionEngine( coordinator.stallStartTime = null coordinator.isCurrentlyStalled = false coordinator.stallArmedByDeload = false + coordinator.stallArmedByRomFraction = false + coordinator.romFractionStallAnchorPosition = null if (coordinator.autoStopStartTime == null && !coordinator.autoStopTriggered) { coordinator._autoStopState.value = AutoStopUiState() } @@ -1743,6 +1831,8 @@ class ActiveSessionEngine( coordinator.stallStartTime = currentTimeMillis() coordinator.isCurrentlyStalled = true coordinator.stallArmedByDeload = false + coordinator.stallArmedByRomFraction = false + coordinator.romFractionStallAnchorPosition = null } else if (isDefinitelyMoving && coordinator.stallStartTime != null) { resetStallTimer() } @@ -1752,6 +1842,8 @@ class ActiveSessionEngine( // F4: re-check per sample — a velocity-armed countdown must not keep // running once the handles return to rest (racked pause). A deload-armed // countdown must survive this (real cable release retracts to ~0mm). + // Issue #673 PR 2: ROM-fraction-armed countdown also cancels at rest, + // same as velocity-armed — racking handles means the user stopped. if (!coordinator.stallArmedByDeload && maxPosition <= WorkoutCoordinator.STALL_MIN_POSITION) { resetStallTimer() return diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt index e1e8a0043..14af7a385 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt @@ -84,6 +84,12 @@ class WorkoutCoordinator( /** Minimum position range to consider "meaningful" for auto-stop detection (in mm) */ const val MIN_RANGE_THRESHOLD = 50f + /** + * Minimum cable travel that proves deliberate progress while the ROM-fraction + * stall countdown is armed. Five millimetres filters ordinary sample noise. + */ + const val ROM_FRACTION_STALL_PROGRESS_THRESHOLD_MM = 5f + /** Issue #204: Startup grace period for AMRAP exercises (ms) * Prevents auto-stop from triggering before user has time to grab handles * when transitioning from a normal rep-based exercise to an AMRAP exercise. @@ -431,6 +437,30 @@ class WorkoutCoordinator( @Volatile internal var stallArmedByDeload = false + // True only while the current stall countdown was armed by the ROM-fraction + // collector. That collector may cancel its own countdown when later status + // samples leave the geometric/velocity window, but must not cancel a timer + // that was upgraded to the stronger DELOAD signal. + @Volatile + internal var stallArmedByRomFraction = false + + // Position at which the current ROM-fraction countdown was armed or last + // refreshed. A later qualifying sample must travel far enough from this + // anchor to prove slow but deliberate cable progress. + @Volatile + internal var romFractionStallAnchorPosition: Float? = null + + // Issue #673 PR 2: ROM-fraction stall detection state. + // Geometric signal: when velocity is in the dead band (2.5–10 mm/s) AND + // the cable position is mid-ROM (30–80% of observed range), the user is + // likely pressing against the machine without moving — arm a secondary + // stall countdown that does NOT depend on firmware DELOAD_OCCURRED. + @Volatile + internal var romRangeTop: Float? = null + + @Volatile + internal var romRangeBottom: Float? = null + // Issue #649: defer position/stall auto-stop until the verbal-cue + short // transition window elapses, or a completed working rep clears it. The // deadline (@Volatile Long) is the single source of truth — 0L means no @@ -458,6 +488,11 @@ class WorkoutCoordinator( stallStartTime = null isCurrentlyStalled = false stallArmedByDeload = false + stallArmedByRomFraction = false + romFractionStallAnchorPosition = null + // Issue #673 PR 2: clear ROM-fraction stall state on set start/reset + romRangeTop = null + romRangeBottom = null deferAutoStopDeadlineMs = 0L _autoStopState.value = AutoStopUiState() } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/MonitorDataProcessorStatusEventTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/MonitorDataProcessorStatusEventTest.kt new file mode 100644 index 000000000..25abbd50a --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/MonitorDataProcessorStatusEventTest.kt @@ -0,0 +1,127 @@ +package com.devil.phoenixproject.data.ble + +import com.devil.phoenixproject.domain.model.MachineStatusEvent +import com.devil.phoenixproject.domain.model.SampleStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Issue #673 PR 2: Tests for MachineStatusEvent emission from MonitorDataProcessor. + * + * Verifies that the onStatusEvent callback fires after velocity smoothing with + * correct position and velocity values, and that the existing deload/ROM-violation + * callbacks remain functional. + */ +class MonitorDataProcessorStatusEventTest { + + @Test + fun `onStatusEvent fires for non-zero status after velocity smoothing`() { + val events = mutableListOf() + val processor = MonitorDataProcessor( + onStatusEvent = { events.add(it) }, + timeProvider = { 3000L }, // > DELOAD_EVENT_DEBOUNCE_MS so debounce never blocks + ) + + // Build a packet with DELOAD_OCCURRED flag (bit 15 = 0x8000) + val packet = MonitorPacket( + ticks = 0L, + posA = 200.0f, + posB = 200.0f, + loadA = 10.0f, + loadB = 10.0f, + firmwareVelA = 50, // 5.0 mm/s + firmwareVelB = 50, + status = 0x8000, // DELOAD_OCCURRED + ) + + processor.process(packet) + + assertEquals(1, events.size) + val event = events[0] + assertEquals(0x8000, event.sampleStatus.raw) + assertTrue(event.sampleStatus.isDeloadOccurred()) + assertEquals(200.0f, event.position) // max(200, 200) + // Velocity is max(smoothedA, smoothedB) — first sample seeds EMA + assertTrue(event.velocity > 0f) + } + + @Test + fun `onStatusEvent fires for zero status with SampleStatus zero`() { + val events = mutableListOf() + val processor = MonitorDataProcessor( + onStatusEvent = { events.add(it) }, + timeProvider = { 1000L }, + ) + + val packet = MonitorPacket( + ticks = 0L, + posA = 200.0f, + posB = 200.0f, + loadA = 10.0f, + loadB = 10.0f, + firmwareVelA = 50, + firmwareVelB = 50, + status = 0, // No flags + ) + + processor.process(packet) + + // Now fires for every processed packet (ROM-fraction collector needs continuous data) + assertEquals(1, events.size) + assertEquals(0, events[0].sampleStatus.raw) + } + + @Test + fun `onStatusEvent uses max position of A and B`() { + val events = mutableListOf() + val processor = MonitorDataProcessor( + onStatusEvent = { events.add(it) }, + timeProvider = { 1000L }, + ) + + val packet = MonitorPacket( + ticks = 0L, + posA = 100.0f, + posB = 350.0f, + loadA = 10.0f, + loadB = 10.0f, + firmwareVelA = 50, + firmwareVelB = 100, // B is faster + status = 0x01, // REP_TOP_READY + ) + + processor.process(packet) + + assertEquals(1, events.size) + assertEquals(350.0f, events[0].position) // max(100, 350) + } + + @Test + fun `onStatusEvent preserves existing deload callback`() { + var deloadFired = false + val events = mutableListOf() + val processor = MonitorDataProcessor( + onDeloadOccurred = { deloadFired = true }, + onStatusEvent = { events.add(it) }, + timeProvider = { 3000L }, // > DELOAD_EVENT_DEBOUNCE_MS so debounce never blocks + ) + + val packet = MonitorPacket( + ticks = 0L, + posA = 200.0f, + posB = 200.0f, + loadA = 10.0f, + loadB = 10.0f, + firmwareVelA = 50, + firmwareVelB = 50, + status = 0x8000, // DELOAD_OCCURRED + ) + + processor.process(packet) + + assertTrue(deloadFired, "deloadOccurred callback must still fire") + assertEquals(1, events.size, "onStatusEvent must also fire") + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt index 29891ea42..7eabf9046 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt @@ -12,7 +12,9 @@ import com.devil.phoenixproject.domain.model.EchoLevel import com.devil.phoenixproject.domain.model.Exercise import com.devil.phoenixproject.domain.model.ExerciseCableIntent import com.devil.phoenixproject.domain.model.HapticEvent +import com.devil.phoenixproject.domain.model.MachineStatusEvent import com.devil.phoenixproject.domain.model.PRType +import com.devil.phoenixproject.domain.model.SampleStatus import com.devil.phoenixproject.domain.model.PersonalRecord import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.RepCount @@ -967,6 +969,232 @@ class DWSMWorkoutLifecycleTest { harness.cleanup() } + @Test + fun `Issue 673 warmup status samples calibrate ROM before stall arming`() = runTest { + val harness = DWSMTestHarness(this) + try { + harness.fakeBleRepo.simulateConnect("Vee_Test") + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + warmupReps = 0, + weightPerCableKg = 35f, + stallDetectionEnabled = true, + isAMRAP = false, + isJustLift = false, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true) + advanceUntilIdle() + assertIs(harness.dwsm.coordinator.workoutState.value) + assertFalse(harness.dwsm.coordinator.repCount.value.isWarmupComplete) + + // These arrive before the warmup gate opens: they must calibrate ROM, + // but must not arm an auto-stop stall timer yet. + harness.fakeBleRepo.emitMachineStatusEvent( + MachineStatusEvent(harness.nowMs, SampleStatus(0), position = 0f, velocity = 5f), + ) + harness.fakeBleRepo.emitMachineStatusEvent( + MachineStatusEvent(harness.nowMs + 1L, SampleStatus(0), position = 100f, velocity = 5f), + ) + advanceUntilIdle() + assertEquals(null, harness.dwsm.coordinator.stallStartTime) + + completeWarmupReps(harness, warmupTarget = 3, workingTarget = 8) + completeFirstWorkingRep(harness, warmupTarget = 3, workingTarget = 8) + advanceUntilIdle() + assertTrue(harness.dwsm.coordinator.repCount.value.isWarmupComplete) + assertEquals(1, harness.dwsm.coordinator.repCount.value.workingReps) + + // With the 0–100 mm warmup calibration retained, 50 mm is mid-ROM + // and 5 mm/s is inside the stall dead band, so the real engine arms. + harness.fakeBleRepo.emitMachineStatusEvent( + MachineStatusEvent(harness.nowMs + 2L, SampleStatus(0), position = 50f, velocity = 5f), + ) + advanceUntilIdle() + + assertNotNull( + harness.dwsm.coordinator.stallStartTime, + "Warmup status samples must calibrate ROM so a later mid-ROM dead-band event can arm the stall timer", + ) + assertEquals(100f, harness.dwsm.coordinator.romRangeTop) + assertEquals(0f, harness.dwsm.coordinator.romRangeBottom) + } finally { + harness.cleanup() + } + } + + @Test + fun `Issue 673 ROM fraction ignores events outside mid ROM or dead band`() = runTest { + val ignoredEvents = listOf( + Triple(90f, 5f, "outside the 30 to 80 percent mid-ROM band"), + Triple(50f, 10.1f, "outside the 2.5 to 10 mm per second dead band"), + ) + for ((position, velocity, description) in ignoredEvents) { + val harness = DWSMTestHarness(this) + try { + prepareRomFractionStallHarness(harness) { advanceUntilIdle() } + assertTrue(harness.dwsm.coordinator.repCount.value.isWarmupComplete) + assertEquals(1, harness.dwsm.coordinator.repCount.value.workingReps) + + harness.fakeBleRepo.emitMachineStatusEvent( + MachineStatusEvent(harness.nowMs + 2L, SampleStatus(0), position, velocity), + ) + advanceUntilIdle() + + assertEquals( + null, + harness.dwsm.coordinator.stallStartTime, + "ROM-fraction event $description must not arm the stall timer", + ) + } finally { + harness.cleanup() + } + } + } + + @Test + fun `Issue 673 ROM fraction timer cancels when status leaves qualifying window`() = runTest { + val harness = DWSMTestHarness(this) + try { + prepareRomFractionStallHarness(harness) { advanceUntilIdle() } + + // Mid-ROM at 5 mm/s arms the ROM-fraction countdown. + harness.fakeBleRepo.emitMachineStatusEvent( + MachineStatusEvent(harness.nowMs + 2L, SampleStatus(0), position = 50f, velocity = 5f), + ) + advanceUntilIdle() + assertNotNull(harness.dwsm.coordinator.stallStartTime) + assertTrue(harness.dwsm.coordinator.isCurrentlyStalled) + + // A later sample outside the 30–80% window means the geometric + // condition no longer holds, so the ROM-specific countdown must stop. + harness.fakeBleRepo.emitMachineStatusEvent( + MachineStatusEvent(harness.nowMs + 3L, SampleStatus(0), position = 90f, velocity = 5f), + ) + advanceUntilIdle() + + assertEquals(null, harness.dwsm.coordinator.stallStartTime) + assertFalse(harness.dwsm.coordinator.isCurrentlyStalled) + assertFalse(harness.dwsm.coordinator.autoStopState.value.isActive) + } finally { + harness.cleanup() + } + } + + @Test + fun `Issue 697 ROM fraction progress resets a backdated stall timer`() = runTest { + val harness = DWSMTestHarness(this) + try { + prepareRomFractionStallHarness(harness) { advanceUntilIdle() } + + // Establish a ROM-fraction countdown at 50% ROM, then make its + // deadline intentionally stale. A 10 mm move at 5 mm/s remains in + // the dead band but is deliberate cable progress, not a stall. + harness.fakeBleRepo.emitMachineStatusEvent( + MachineStatusEvent(harness.nowMs + 2L, SampleStatus(0), position = 50f, velocity = 5f), + ) + advanceUntilIdle() + assertNotNull(harness.dwsm.coordinator.stallStartTime) + + val expiredTimer = currentTimeMillis() - 6_000L + harness.dwsm.coordinator.stallStartTime = expiredTimer + harness.fakeBleRepo.emitMachineStatusEvent( + MachineStatusEvent(harness.nowMs + 3L, SampleStatus(0), position = 60f, velocity = 5f), + ) + advanceUntilIdle() + + val refreshedTimer = assertNotNull(harness.dwsm.coordinator.stallStartTime) + assertTrue( + refreshedTimer > expiredTimer, + "A 10 mm in-window ROM advance at 5 mm/s must reset the stall countdown", + ) + assertTrue(harness.dwsm.coordinator.stallArmedByRomFraction) + assertFalse(harness.dwsm.coordinator.stallArmedByDeload) + } finally { + harness.cleanup() + } + } + + @Test + fun `Issue 697 out of window status does not clear a deload upgraded ROM timer`() = runTest { + val harness = DWSMTestHarness(this) + try { + prepareRomFractionStallHarness(harness) { advanceUntilIdle() } + + harness.fakeBleRepo.emitMachineStatusEvent( + MachineStatusEvent(harness.nowMs + 2L, SampleStatus(0), position = 50f, velocity = 5f), + ) + advanceUntilIdle() + val romTimer = assertNotNull(harness.dwsm.coordinator.stallStartTime) + assertTrue(harness.dwsm.coordinator.stallArmedByRomFraction) + + // DELOAD is a stronger event and now owns the existing timer. + harness.fakeBleRepo.emitDeloadOccurred() + advanceUntilIdle() + assertTrue(harness.dwsm.coordinator.stallArmedByDeload) + assertFalse(harness.dwsm.coordinator.stallArmedByRomFraction) + + // 90% ROM leaves the ROM-fraction window. It must not clear the + // timer after DELOAD has taken ownership. + harness.fakeBleRepo.emitMachineStatusEvent( + MachineStatusEvent(harness.nowMs + 3L, SampleStatus(0), position = 90f, velocity = 5f), + ) + advanceUntilIdle() + + assertEquals( + romTimer, + harness.dwsm.coordinator.stallStartTime, + "An out-of-window status sample must not clear a DELOAD-owned timer", + ) + assertTrue(harness.dwsm.coordinator.stallArmedByDeload) + } finally { + harness.cleanup() + } + } + + @Test + fun `Issue 673 completed working rep cancels ROM fraction stall timer`() = runTest { + val harness = DWSMTestHarness(this) + try { + prepareRomFractionStallHarness(harness) { advanceUntilIdle() } + assertEquals(1, harness.dwsm.coordinator.repCount.value.workingReps) + + harness.fakeBleRepo.emitMachineStatusEvent( + MachineStatusEvent(harness.nowMs + 2L, SampleStatus(0), position = 50f, velocity = 5f), + ) + advanceUntilIdle() + assertNotNull(harness.dwsm.coordinator.stallStartTime) + + harness.fakeBleRepo.emitRepNotification( + RepNotification( + topCounter = 5, + completeCounter = 5, + repsRomCount = 3, + repsRomTotal = 3, + repsSetCount = 2, + repsSetTotal = 8, + rangeTop = 800f, + rangeBottom = 0f, + rawData = ByteArray(24), + timestamp = harness.nowMs + 3L, + ), + ) + advanceUntilIdle() + + assertEquals(2, harness.dwsm.coordinator.repCount.value.workingReps) + assertEquals( + null, + harness.dwsm.coordinator.stallStartTime, + "A completed working rep must cancel a ROM-fraction-armed stall timer", + ) + assertFalse(harness.dwsm.coordinator.isCurrentlyStalled) + } finally { + harness.cleanup() + } + } + @Test fun `deload does not start stall timer before warmup is complete`() = runTest { val harness = DWSMTestHarness(this) @@ -2236,6 +2464,38 @@ class DWSMWorkoutLifecycleTest { harness.cleanup() } + private suspend fun prepareRomFractionStallHarness( + harness: DWSMTestHarness, + advance: suspend () -> Unit, + ) { + harness.fakeBleRepo.simulateConnect("Vee_Test") + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + warmupReps = 0, + weightPerCableKg = 35f, + stallDetectionEnabled = true, + isAMRAP = false, + isJustLift = false, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true) + advance() + + harness.fakeBleRepo.emitMachineStatusEvent( + MachineStatusEvent(harness.nowMs, SampleStatus(0), position = 0f, velocity = 5f), + ) + harness.fakeBleRepo.emitMachineStatusEvent( + MachineStatusEvent(harness.nowMs + 1L, SampleStatus(0), position = 100f, velocity = 5f), + ) + advance() + + completeWarmupReps(harness, warmupTarget = 3, workingTarget = 8) + completeFirstWorkingRep(harness, warmupTarget = 3, workingTarget = 8) + advance() + } + private suspend fun completeWarmupReps(harness: DWSMTestHarness, warmupTarget: Int = 3, workingTarget: Int = 8) { val activeMetric = WorkoutMetric( positionA = 120f, diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt index 1a53c431f..9917f7fdb 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt @@ -24,6 +24,9 @@ class WorkoutCoordinatorAutoStopResetTest { coordinator.stallStartTime = 67_890L coordinator.isCurrentlyStalled = true coordinator.stallArmedByDeload = true + // Issue #673 PR 2: ROM-fraction stall state + coordinator.romRangeTop = 500.0f + coordinator.romRangeBottom = 100.0f coordinator.deferAutoStopDeadlineMs = 99_999L coordinator._autoStopState.value = AutoStopUiState( isActive = true, @@ -39,6 +42,9 @@ class WorkoutCoordinatorAutoStopResetTest { assertEquals(null, coordinator.stallStartTime) assertFalse(coordinator.isCurrentlyStalled) assertFalse(coordinator.stallArmedByDeload) + // Issue #673 PR 2: ROM-fraction state must also be cleared + assertEquals(null, coordinator.romRangeTop) + assertEquals(null, coordinator.romRangeBottom) assertEquals(0L, coordinator.deferAutoStopDeadlineMs) assertEquals(AutoStopUiState(), coordinator._autoStopState.value) } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeBleRepository.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeBleRepository.kt index 68a234f15..88d1cb2b5 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeBleRepository.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeBleRepository.kt @@ -9,6 +9,7 @@ import com.devil.phoenixproject.data.repository.RepNotification import com.devil.phoenixproject.data.repository.ScannedDevice import com.devil.phoenixproject.domain.model.ConnectionState import com.devil.phoenixproject.domain.model.HeuristicStatistics +import com.devil.phoenixproject.domain.model.MachineStatusEvent import com.devil.phoenixproject.domain.model.WorkoutMetric import com.devil.phoenixproject.domain.model.WorkoutParameters import kotlinx.coroutines.flow.Flow @@ -46,6 +47,9 @@ class FakeBleRepository : BleRepository { private val _deloadOccurredEvents = MutableSharedFlow(replay = 0) override val deloadOccurredEvents: Flow = _deloadOccurredEvents.asSharedFlow() + private val _machineStatusEvents = MutableSharedFlow(replay = 0) + override val machineStatusEvents: Flow = _machineStatusEvents.asSharedFlow() + private val _reconnectionRequested = MutableSharedFlow(replay = 0) override val reconnectionRequested: Flow = _reconnectionRequested.asSharedFlow() @@ -122,6 +126,10 @@ class FakeBleRepository : BleRepository { _deloadOccurredEvents.emit(Unit) } + suspend fun emitMachineStatusEvent(event: MachineStatusEvent) { + _machineStatusEvents.emit(event) + } + suspend fun emitReconnectionRequest(request: ReconnectionRequest) { _reconnectionRequested.emit(request) }