From 7cba1cd74bac556f6fd9ccb296899e64577b9fb6 Mon Sep 17 00:00:00 2001 From: phoenixworker Date: Fri, 14 Aug 2026 18:07:23 -0400 Subject: [PATCH 1/8] feat(#673): MachineStatusEvent flow and ROM-fraction stall detection Refs #673 PR 2 of 3: Widen the narrow deloadOccurredEvents Flow to a rich Flow carrying the full SampleStatus + position + velocity. Add a geometric ROM-fraction stall signal as a secondary arm path for set-ending detection. Changes: - New MachineStatusEvent data class (timestamp, sampleStatus, position, velocity) - BleRepository: add machineStatusEvents: Flow - MonitorDataProcessor: add onStatusEvent callback, fire after velocity smoothing with max(A,B) position and max(A,B) smoothed velocity - KableBleRepository: emit MachineStatusEvent from monitorProcessor callback, derive deloadOccurredEvents for backward compatibility - FakeBleRepository: add machineStatusEvents test double - WorkoutCoordinator: add ROM calibration state (romRangeTop, romRangeBottom, romFraction, stallArmedByRomFraction) - ActiveSessionEngine: add collector #5b consuming machineStatusEvents, tracking ROM range from position observations, computing ROM fraction, and arming stall timer when velocity is in dead band (2.5-10 mm/s) AND position is mid-ROM (30-80%). Rep events cancel position-armed countdown via existing resetStallTimer(). - Tests: MonitorDataProcessorStatusEventTest (4 tests), WorkoutCoordinatorAutoStopResetTest updated for ROM-fraction fields --- .../data/ble/MonitorDataProcessor.kt | 18 +++ .../data/repository/BleRepository.kt | 4 + .../data/repository/KableBleRepository.kt | 14 ++ .../domain/model/MachineStatusEvent.kt | 19 +++ .../manager/ActiveSessionEngine.kt | 60 +++++++++ .../manager/WorkoutCoordinator.kt | 24 ++++ .../MonitorDataProcessorStatusEventTest.kt | 125 ++++++++++++++++++ .../WorkoutCoordinatorAutoStopResetTest.kt | 10 ++ .../testutil/FakeBleRepository.kt | 8 ++ 9 files changed, 282 insertions(+) create mode 100644 shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/MachineStatusEvent.kt create mode 100644 shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/MonitorDataProcessorStatusEventTest.kt 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..df362cfc8 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,21 @@ 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. + // Only fires on packets that carry a non-zero status word. + if (packet.status != 0) { + onStatusEvent( + MachineStatusEvent( + timestamp = currentTime, + sampleStatus = SampleStatus(packet.status), + position = max(posA, posB), + velocity = max(smoothedVelocityA, 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..701241b01 --- /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 BLE monitor + * sample that has a non-zero status word. 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..33765f289 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 @@ -670,6 +670,62 @@ 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 + if (!isWarmupGateOpenForAutoStop()) return@collect + if (!shouldEnableAutoStop(params)) return@collect + + // Track ROM range from position observations + 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 + + 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 + coordinator.romFraction = fraction + + 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.stallArmedByRomFraction = true + Logger.d("Auto-stop stall timer STARTED via ROM-fraction signal (fraction=$fraction, velocity=$velocity)") + } + } + } + } + // #6: Rep events collector for handling machine rep notifications coordinator.repEventsCollectionJob = scope.launch { bleRepository.repEvents @@ -1183,6 +1239,8 @@ class ActiveSessionEngine( coordinator.stallStartTime = null coordinator.isCurrentlyStalled = false coordinator.stallArmedByDeload = false + // Issue #673 PR 2: also clear ROM-fraction stall arm + coordinator.stallArmedByRomFraction = false if (coordinator.autoStopStartTime == null && !coordinator.autoStopTriggered) { coordinator._autoStopState.value = AutoStopUiState() } @@ -1752,6 +1810,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..42568771a 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 @@ -431,6 +431,25 @@ class WorkoutCoordinator( @Volatile internal var stallArmedByDeload = false + // 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 + + @Volatile + internal var romFraction: Float? = null + + // True when the stall countdown was armed by the ROM-fraction geometric + // signal (velocity dead band + mid-ROM position). Rep events cancel it. + @Volatile + internal var stallArmedByRomFraction = false + // 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 +477,11 @@ class WorkoutCoordinator( stallStartTime = null isCurrentlyStalled = false stallArmedByDeload = false + // Issue #673 PR 2: clear ROM-fraction stall state on set start/reset + romRangeTop = null + romRangeBottom = null + romFraction = null + stallArmedByRomFraction = false 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..212f497a0 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/MonitorDataProcessorStatusEventTest.kt @@ -0,0 +1,125 @@ +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 = { 1000L }, + ) + + // Build a packet with DELOAD_OCCURRED flag (bit 7 = 0x80) + 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 = 0x80, // DELOAD_OCCURRED + ) + + processor.process(packet) + + assertEquals(1, events.size) + val event = events[0] + assertEquals(0x80, 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 does not fire for zero status`() { + 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) + + assertEquals(0, events.size) + } + + @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 = { 1000L }, + ) + + val packet = MonitorPacket( + ticks = 0L, + posA = 200.0f, + posB = 200.0f, + loadA = 10.0f, + loadB = 10.0f, + firmwareVelA = 50, + firmwareVelB = 50, + status = 0x80, // 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/WorkoutCoordinatorAutoStopResetTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt index 1a53c431f..bf7ae6d87 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,11 @@ 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.romFraction = 0.6f + coordinator.stallArmedByRomFraction = true coordinator.deferAutoStopDeadlineMs = 99_999L coordinator._autoStopState.value = AutoStopUiState( isActive = true, @@ -39,6 +44,11 @@ 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(null, coordinator.romFraction) + assertFalse(coordinator.stallArmedByRomFraction) 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) } From 333a1c40a5f7d3c15dd24ca5936fe47b68a76225 Mon Sep 17 00:00:00 2001 From: Phoenix Worker Date: Fri, 14 Aug 2026 18:15:46 -0400 Subject: [PATCH 2/8] fix: correct DELOAD_OCCURRED bitmask in status event tests DELOAD_OCCURRED is bit 15 (0x8000), not bit 7 (0x80). Tests were using the wrong bitmask, causing isDeloadOccurred() to return false. Also increase timeProvider to 3000L so the 2-second deload debounce does not suppress the callback in tests. --- .../data/ble/MonitorDataProcessorStatusEventTest.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 index 212f497a0..3a65983ac 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/MonitorDataProcessorStatusEventTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/MonitorDataProcessorStatusEventTest.kt @@ -21,10 +21,10 @@ class MonitorDataProcessorStatusEventTest { val events = mutableListOf() val processor = MonitorDataProcessor( onStatusEvent = { events.add(it) }, - timeProvider = { 1000L }, + timeProvider = { 3000L }, // > DELOAD_EVENT_DEBOUNCE_MS so debounce never blocks ) - // Build a packet with DELOAD_OCCURRED flag (bit 7 = 0x80) + // Build a packet with DELOAD_OCCURRED flag (bit 15 = 0x8000) val packet = MonitorPacket( ticks = 0L, posA = 200.0f, @@ -33,14 +33,14 @@ class MonitorDataProcessorStatusEventTest { loadB = 10.0f, firmwareVelA = 50, // 5.0 mm/s firmwareVelB = 50, - status = 0x80, // DELOAD_OCCURRED + status = 0x8000, // DELOAD_OCCURRED ) processor.process(packet) assertEquals(1, events.size) val event = events[0] - assertEquals(0x80, event.sampleStatus.raw) + 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 @@ -103,7 +103,7 @@ class MonitorDataProcessorStatusEventTest { val processor = MonitorDataProcessor( onDeloadOccurred = { deloadFired = true }, onStatusEvent = { events.add(it) }, - timeProvider = { 1000L }, + timeProvider = { 3000L }, // > DELOAD_EVENT_DEBOUNCE_MS so debounce never blocks ) val packet = MonitorPacket( @@ -114,7 +114,7 @@ class MonitorDataProcessorStatusEventTest { loadB = 10.0f, firmwareVelA = 50, firmwareVelB = 50, - status = 0x80, // DELOAD_OCCURRED + status = 0x8000, // DELOAD_OCCURRED ) processor.process(packet) From 6e09b2b2ddfbd84df63da15ad9a828e05f3349c5 Mon Sep 17 00:00:00 2001 From: Phoenix Worker Date: Fri, 14 Aug 2026 18:29:43 -0400 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20emit=20status=20events=20for=20every=20packet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Remove status!=0 gate: emit MachineStatusEvent for every processed packet so ROM-fraction stall detector gets continuous position/velocity data, not just edge events (status flags). 2. Use abs() for velocity magnitude: firmware velocities can be negative during eccentric cable movement; take absolute max so dead-band comparison works correctly. 3. Move warmup gate after ROM range tracking: learn the ROM range during warmup so the detector has a calibrated range when working reps begin. 4. Remove dead romFraction and stallArmedByRomFraction fields: neither is read by any control flow; only romRangeTop/Bottom are used. 5. Update tests to match new always-emit behavior and removed fields. --- .../data/ble/MonitorDataProcessor.kt | 21 +++++++++---------- .../manager/ActiveSessionEngine.kt | 9 ++++---- .../manager/WorkoutCoordinator.kt | 10 --------- .../MonitorDataProcessorStatusEventTest.kt | 6 ++++-- .../WorkoutCoordinatorAutoStopResetTest.kt | 4 ---- 5 files changed, 19 insertions(+), 31 deletions(-) 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 df362cfc8..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 @@ -226,17 +226,16 @@ class MonitorDataProcessor( // ===== STAGE 6B: STATUS EVENT EMISSION ===== // Issue #673 PR 2: emit MachineStatusEvent carrying the full SampleStatus + // position + velocity for downstream ROM-fraction stall detection. - // Only fires on packets that carry a non-zero status word. - if (packet.status != 0) { - onStatusEvent( - MachineStatusEvent( - timestamp = currentTime, - sampleStatus = SampleStatus(packet.status), - position = max(posA, posB), - velocity = max(smoothedVelocityA, smoothedVelocityB).toFloat(), - ), - ) - } + // 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( 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 33765f289..b916ecbc3 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 @@ -684,23 +684,25 @@ class ActiveSessionEngine( if (!params.stallDetectionEnabled || currentState !is WorkoutState.Active) return@collect if (params.isEchoMode) return@collect - if (!isWarmupGateOpenForAutoStop()) return@collect if (!shouldEnableAutoStop(params)) return@collect - // Track ROM range from position observations + // 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 + if (!isWarmupGateOpenForAutoStop()) 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 - coordinator.romFraction = fraction val velocity = event.velocity.toDouble() @@ -719,7 +721,6 @@ class ActiveSessionEngine( if (coordinator.stallStartTime == null) { coordinator.stallStartTime = currentTimeMillis() coordinator.isCurrentlyStalled = true - coordinator.stallArmedByRomFraction = true Logger.d("Auto-stop stall timer STARTED via ROM-fraction signal (fraction=$fraction, velocity=$velocity)") } } 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 42568771a..954eea42d 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 @@ -442,14 +442,6 @@ class WorkoutCoordinator( @Volatile internal var romRangeBottom: Float? = null - @Volatile - internal var romFraction: Float? = null - - // True when the stall countdown was armed by the ROM-fraction geometric - // signal (velocity dead band + mid-ROM position). Rep events cancel it. - @Volatile - internal var stallArmedByRomFraction = false - // 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 @@ -480,8 +472,6 @@ class WorkoutCoordinator( // Issue #673 PR 2: clear ROM-fraction stall state on set start/reset romRangeTop = null romRangeBottom = null - romFraction = null - stallArmedByRomFraction = false 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 index 3a65983ac..14912dcb8 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/MonitorDataProcessorStatusEventTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/MonitorDataProcessorStatusEventTest.kt @@ -48,7 +48,7 @@ class MonitorDataProcessorStatusEventTest { } @Test - fun `onStatusEvent does not fire for zero status`() { + fun `onStatusEvent fires for zero status with SampleStatus(0)`() { val events = mutableListOf() val processor = MonitorDataProcessor( onStatusEvent = { events.add(it) }, @@ -68,7 +68,9 @@ class MonitorDataProcessorStatusEventTest { processor.process(packet) - assertEquals(0, events.size) + // Now fires for every processed packet (ROM-fraction collector needs continuous data) + assertEquals(1, events.size) + assertEquals(0, events[0].sampleStatus.raw) } @Test 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 bf7ae6d87..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 @@ -27,8 +27,6 @@ class WorkoutCoordinatorAutoStopResetTest { // Issue #673 PR 2: ROM-fraction stall state coordinator.romRangeTop = 500.0f coordinator.romRangeBottom = 100.0f - coordinator.romFraction = 0.6f - coordinator.stallArmedByRomFraction = true coordinator.deferAutoStopDeadlineMs = 99_999L coordinator._autoStopState.value = AutoStopUiState( isActive = true, @@ -47,8 +45,6 @@ class WorkoutCoordinatorAutoStopResetTest { // Issue #673 PR 2: ROM-fraction state must also be cleared assertEquals(null, coordinator.romRangeTop) assertEquals(null, coordinator.romRangeBottom) - assertEquals(null, coordinator.romFraction) - assertFalse(coordinator.stallArmedByRomFraction) assertEquals(0L, coordinator.deferAutoStopDeadlineMs) assertEquals(AutoStopUiState(), coordinator._autoStopState.value) } From d41d82224c4345c0d06b2581897c6e9b3ed82e97 Mon Sep 17 00:00:00 2001 From: Phoenix Worker Date: Fri, 14 Aug 2026 18:34:28 -0400 Subject: [PATCH 4/8] fix: remove remaining stallArmedByRomFraction reference in resetStallTimer() --- .../phoenixproject/presentation/manager/ActiveSessionEngine.kt | 2 -- 1 file changed, 2 deletions(-) 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 b916ecbc3..6d6bfec0a 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 @@ -1240,8 +1240,6 @@ class ActiveSessionEngine( coordinator.stallStartTime = null coordinator.isCurrentlyStalled = false coordinator.stallArmedByDeload = false - // Issue #673 PR 2: also clear ROM-fraction stall arm - coordinator.stallArmedByRomFraction = false if (coordinator.autoStopStartTime == null && !coordinator.autoStopTriggered) { coordinator._autoStopState.value = AutoStopUiState() } From 18a41383c113382a84d0790de71492a31f0799cf Mon Sep 17 00:00:00 2001 From: Phoenix Worker Date: Fri, 14 Aug 2026 18:43:24 -0400 Subject: [PATCH 5/8] fix: remove parentheses from test name for iOS/native compatibility Kotlin/native test runner rejects method names containing parentheses. --- .../data/ble/MonitorDataProcessorStatusEventTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 14912dcb8..25abbd50a 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/MonitorDataProcessorStatusEventTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/MonitorDataProcessorStatusEventTest.kt @@ -48,7 +48,7 @@ class MonitorDataProcessorStatusEventTest { } @Test - fun `onStatusEvent fires for zero status with SampleStatus(0)`() { + fun `onStatusEvent fires for zero status with SampleStatus zero`() { val events = mutableListOf() val processor = MonitorDataProcessor( onStatusEvent = { events.add(it) }, From 37a94293248013056833d3ff8c086bc8d014437a Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 14 Aug 2026 19:16:09 -0400 Subject: [PATCH 6/8] fix: calibrate ROM during warmup --- .../domain/model/MachineStatusEvent.kt | 4 +- .../manager/ActiveSessionEngine.kt | 5 +- .../manager/DWSMWorkoutLifecycleTest.kt | 160 ++++++++++++++++++ 3 files changed, 164 insertions(+), 5 deletions(-) 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 index 701241b01..a5071ed30 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/MachineStatusEvent.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/MachineStatusEvent.kt @@ -1,8 +1,8 @@ package com.devil.phoenixproject.domain.model /** - * Carries the full machine status-word, position, and velocity from every BLE monitor - * sample that has a non-zero status word. Supersedes the narrow [Unit]-typed + * 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). * 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 6d6bfec0a..12c832a2d 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 @@ -684,7 +684,6 @@ class ActiveSessionEngine( if (!params.stallDetectionEnabled || currentState !is WorkoutState.Active) return@collect if (params.isEchoMode) return@collect - if (!shouldEnableAutoStop(params)) return@collect // Track ROM range from position observations (even during warmup, // so the detector has a calibrated range when warmup ends) @@ -694,8 +693,8 @@ class ActiveSessionEngine( if (currentTop == null || pos > currentTop) coordinator.romRangeTop = pos if (currentBottom == null || pos < currentBottom) coordinator.romRangeBottom = pos - // Gate timer arming until warmup is complete - if (!isWarmupGateOpenForAutoStop()) return@collect + // 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 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..65a025403 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,132 @@ 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 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 +2364,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, From 09b926dc21c502b49b425a42635a8a4547e83b21 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 14 Aug 2026 19:44:30 -0400 Subject: [PATCH 7/8] fix: cancel stale ROM fraction stall timers --- .../manager/ActiveSessionEngine.kt | 16 ++++++++++ .../manager/WorkoutCoordinator.kt | 8 +++++ .../manager/DWSMWorkoutLifecycleTest.kt | 29 +++++++++++++++++++ 3 files changed, 53 insertions(+) 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 12c832a2d..a6ad8fe17 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 @@ -657,12 +657,14 @@ class ActiveSessionEngine( coordinator.stallStartTime = currentTimeMillis() coordinator.isCurrentlyStalled = true coordinator.stallArmedByDeload = true + coordinator.stallArmedByRomFraction = false 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 } else if (inGrace) { Logger.d("DELOAD_OCCURRED ignored - in AMRAP startup grace period") } @@ -720,8 +722,20 @@ class ActiveSessionEngine( if (coordinator.stallStartTime == null) { coordinator.stallStartTime = currentTimeMillis() coordinator.isCurrentlyStalled = true + coordinator.stallArmedByDeload = false + coordinator.stallArmedByRomFraction = true Logger.d("Auto-stop stall timer STARTED via ROM-fraction signal (fraction=$fraction, 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() } } } @@ -1239,6 +1253,7 @@ class ActiveSessionEngine( coordinator.stallStartTime = null coordinator.isCurrentlyStalled = false coordinator.stallArmedByDeload = false + coordinator.stallArmedByRomFraction = false if (coordinator.autoStopStartTime == null && !coordinator.autoStopTriggered) { coordinator._autoStopState.value = AutoStopUiState() } @@ -1799,6 +1814,7 @@ class ActiveSessionEngine( coordinator.stallStartTime = currentTimeMillis() coordinator.isCurrentlyStalled = true coordinator.stallArmedByDeload = false + coordinator.stallArmedByRomFraction = false } else if (isDefinitelyMoving && coordinator.stallStartTime != null) { resetStallTimer() } 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 954eea42d..eb665a0e9 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 @@ -431,6 +431,13 @@ 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 + // 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 @@ -469,6 +476,7 @@ class WorkoutCoordinator( stallStartTime = null isCurrentlyStalled = false stallArmedByDeload = false + stallArmedByRomFraction = false // Issue #673 PR 2: clear ROM-fraction stall state on set start/reset romRangeTop = null romRangeBottom = null 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 65a025403..277632910 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 @@ -1054,6 +1054,35 @@ class DWSMWorkoutLifecycleTest { } } + @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 673 completed working rep cancels ROM fraction stall timer`() = runTest { val harness = DWSMTestHarness(this) From 47170beabec2823076cc56b32b51a200eed64666 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 14 Aug 2026 20:09:40 -0400 Subject: [PATCH 8/8] fix: reset ROM stall timer on cable progress --- .../manager/ActiveSessionEngine.kt | 18 +++++ .../manager/WorkoutCoordinator.kt | 13 ++++ .../manager/DWSMWorkoutLifecycleTest.kt | 71 +++++++++++++++++++ 3 files changed, 102 insertions(+) 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 a6ad8fe17..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 @@ -658,6 +659,7 @@ class ActiveSessionEngine( 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 @@ -665,6 +667,7 @@ class ActiveSessionEngine( // (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") } @@ -724,7 +727,20 @@ class ActiveSessionEngine( 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 && @@ -1254,6 +1270,7 @@ class ActiveSessionEngine( coordinator.isCurrentlyStalled = false coordinator.stallArmedByDeload = false coordinator.stallArmedByRomFraction = false + coordinator.romFractionStallAnchorPosition = null if (coordinator.autoStopStartTime == null && !coordinator.autoStopTriggered) { coordinator._autoStopState.value = AutoStopUiState() } @@ -1815,6 +1832,7 @@ class ActiveSessionEngine( coordinator.isCurrentlyStalled = true coordinator.stallArmedByDeload = false coordinator.stallArmedByRomFraction = false + coordinator.romFractionStallAnchorPosition = null } else if (isDefinitelyMoving && coordinator.stallStartTime != null) { resetStallTimer() } 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 eb665a0e9..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. @@ -438,6 +444,12 @@ class WorkoutCoordinator( @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 @@ -477,6 +489,7 @@ class WorkoutCoordinator( 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 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 277632910..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 @@ -1083,6 +1083,77 @@ class DWSMWorkoutLifecycleTest { } } + @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)