Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -142,6 +143,9 @@ interface BleRepository {
// Deload safety event (for Just Lift mode safety recovery)
val deloadOccurredEvents: Flow<Unit>

// Full machine status-word events (Issue #673 PR 2: ROM-fraction stall detection)
val machineStatusEvents: Flow<MachineStatusEvent>

// Reconnection request (for auto-recovery on connection loss)
val reconnectionRequested: Flow<ReconnectionRequest>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -83,6 +84,12 @@ class KableBleRepository : BleRepository {
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val deloadOccurredEvents: Flow<Unit> = _deloadOccurredEvents.asSharedFlow()
private val _machineStatusEvents = MutableSharedFlow<MachineStatusEvent>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val machineStatusEvents: Flow<MachineStatusEvent> = _machineStatusEvents.asSharedFlow()
enum class RomViolationType { OUTSIDE_HIGH, OUTSIDE_LOW }
private val _romViolationEvents = MutableSharedFlow<RomViolationType>(
replay = 0,
Expand Down Expand Up @@ -119,6 +126,9 @@ class KableBleRepository : BleRepository {
}
publishSafetyEvent(_romViolationEvents, mapped, BleCriticalEventType.ROM_VIOLATION)
},
onStatusEvent = { event ->
_machineStatusEvents.tryEmit(event)
},
)

private val discoMode = DiscoMode(
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -657,19 +658,104 @@ 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")
}
}
}
}

// #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
Comment thread
9thLevelSoftware marked this conversation as resolved.
.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) {
Comment thread
9thLevelSoftware marked this conversation as resolved.
Comment thread
9thLevelSoftware marked this conversation as resolved.
Comment thread
9thLevelSoftware marked this conversation as resolved.
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
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down
Loading
Loading