From 97904d497196661255c41eeba4c543262577fd86 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 23 Aug 2026 14:01:25 -0400 Subject: [PATCH 1/5] fix: fail-closed chassis limits and cap CONFIG forceMax Unknown and V-Form hardware now cap at 100 kg/cable; Trainer+ stays 110. Validators, packet factory, ASE send site, and weight sliders require PhoenixModel. forceMax is min(weight+10, chassisMax). Echo 0x4E is unchanged and still does not carry kg. --- ...ActiveSessionEngineChassisLimitHostTest.kt | 92 +++++++++ .../presentation/screen/WorkoutTabPreviews.kt | 1 + .../usecase/DropSetCandidateResolver.kt | 4 +- .../usecase/DropSetEligibilityPolicy.kt | 3 + .../components/BulkWeightAdjustDialog.kt | 15 +- .../components/ExerciseConfigModal.kt | 52 ++++- .../presentation/components/WeightStepper.kt | 7 +- .../manager/ActiveSessionEngine.kt | 29 ++- .../screen/ExerciseEditBottomSheet.kt | 5 +- .../presentation/screen/JustLiftScreen.kt | 9 +- .../screen/ModeConfirmationScreen.kt | 5 + .../presentation/screen/RestTimerCard.kt | 6 +- .../screen/RoutineEditorScreen.kt | 5 + .../screen/RoutineOverviewScreen.kt | 7 +- .../presentation/screen/SetReadyScreen.kt | 4 +- .../screen/SingleExerciseScreen.kt | 4 + .../screen/TrainingCyclesScreen.kt | 4 + .../presentation/screen/WorkoutTab.kt | 2 + .../devil/phoenixproject/util/BleConstants.kt | 4 +- .../phoenixproject/util/BlePacketFactory.kt | 27 ++- .../phoenixproject/util/ChassisLimits.kt | 42 ++++ .../devil/phoenixproject/util/Constants.kt | 12 +- .../util/WorkoutCommandValidator.kt | 19 +- .../di/DropSetProductionBindingTest.kt | 1 + .../usecase/DropSetCandidateResolverTest.kt | 1 + .../usecase/DropSetEligibilityPolicyTest.kt | 9 +- .../components/BulkWeightAdjustTest.kt | 115 +++++++---- .../manager/Issue673DropSetProductFlowTest.kt | 2 + .../testutil/FakeBleRepository.kt | 3 + .../util/BlePacketFactoryTest.kt | 194 ++++++++++++------ .../util/WorkoutCommandValidatorTest.kt | 104 +++++++++- 31 files changed, 623 insertions(+), 164 deletions(-) create mode 100644 shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngineChassisLimitHostTest.kt create mode 100644 shared/src/commonMain/kotlin/com/devil/phoenixproject/util/ChassisLimits.kt diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngineChassisLimitHostTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngineChassisLimitHostTest.kt new file mode 100644 index 000000000..a2d1429c8 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngineChassisLimitHostTest.kt @@ -0,0 +1,92 @@ +package com.devil.phoenixproject.presentation.manager + +import com.devil.phoenixproject.domain.model.PhoenixModel +import com.devil.phoenixproject.domain.model.ProgramMode +import com.devil.phoenixproject.domain.model.WorkoutParameters +import com.devil.phoenixproject.testutil.DWSMTestHarness +import com.devil.phoenixproject.util.BleConstants +import com.devil.phoenixproject.util.HardwareDetection +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * Send-site host tests: ASE must reject over-chassis CONFIG weights before BLE write. + * Echo 0x4E does not carry kg; only 0x04 CONFIG floats are chassis-clamped. + */ +class ActiveSessionEngineChassisLimitHostTest { + + @Test + fun `V-Form send site rejects 100_5 kg and does not write CONFIG`() = runTest { + val harness = DWSMTestHarness(this) + val bleErrors = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + harness.coordinator.bleErrorEvents.collect(bleErrors::add) + } + try { + harness.fakeBleRepo.simulateConnect("Vee_Test") + assertEquals( + PhoenixModel.VFormTrainer, + HardwareDetection.detectModel("Vee_Test"), + ) + + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + warmupReps = 0, + weightPerCableKg = 100.5f, + isJustLift = true, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true, isJustLiftMode = true) + advanceUntilIdle() + + val configWrites = harness.fakeBleRepo.commandsReceived.filter { + it.isNotEmpty() && it[0] == BleConstants.Commands.ACTIVATION_COMMAND + } + assertTrue( + configWrites.isEmpty(), + "V-Form must not send CONFIG at 100.5 kg/cable; got ${configWrites.size} writes", + ) + assertTrue( + bleErrors.any { it.contains("Invalid BLE workout command") && it.contains("100.5") }, + "Expected send-site rejection of 100.5 kg on V-Form, got $bleErrors", + ) + } finally { + harness.cleanup() + } + } + + @Test + fun `Trainer+ send site accepts 100_5 kg CONFIG`() = runTest { + val harness = DWSMTestHarness(this) + try { + harness.fakeBleRepo.simulateConnect("VIT_Test") + assertEquals(PhoenixModel.TrainerPlus, HardwareDetection.detectModel("VIT_Test")) + + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + warmupReps = 0, + weightPerCableKg = 100.5f, + isJustLift = true, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true, isJustLiftMode = true) + advanceUntilIdle() + + val configWrites = harness.fakeBleRepo.commandsReceived.filter { + it.isNotEmpty() && it[0] == BleConstants.Commands.ACTIVATION_COMMAND + } + assertTrue(configWrites.isNotEmpty(), "Trainer+ must send CONFIG at 100.5 kg/cable") + } finally { + harness.cleanup() + } + } +} diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTabPreviews.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTabPreviews.kt index 9364a403a..38ad62d18 100644 --- a/shared/src/androidMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTabPreviews.kt +++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTabPreviews.kt @@ -1217,6 +1217,7 @@ private fun RestTimerDropSetUnresolvedPreview() { nextExerciseReps = 8, onSkipRest = {}, onEndWorkout = {}, + hardwareModel = com.devil.phoenixproject.domain.model.PhoenixModel.Unknown, dropSetOffer = DropSetOfferUiState.Unresolved( context = DropSetOfferContext( identity = com.devil.phoenixproject.presentation.manager.RestActionIdentity( diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/DropSetCandidateResolver.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/DropSetCandidateResolver.kt index 230bcb206..a96a5a056 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/DropSetCandidateResolver.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/DropSetCandidateResolver.kt @@ -4,6 +4,7 @@ import com.devil.phoenixproject.domain.model.DropPercentage import com.devil.phoenixproject.domain.model.DropSetCandidate import com.devil.phoenixproject.domain.model.DropSetCandidateInvalidReason import com.devil.phoenixproject.domain.model.DropSetCandidateResolution +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.WorkoutParameters import com.devil.phoenixproject.util.UnitConverter import com.devil.phoenixproject.util.WorkoutCommandValidator @@ -14,6 +15,7 @@ data class DropSetCandidateRequest( val programmedBaseWeightPerCableKg: Float, val minimumWeightPerCableKg: Float, val commandTemplate: WorkoutParameters, + val hardwareModel: PhoenixModel, ) class DropSetCandidateResolver { @@ -38,7 +40,7 @@ class DropSetCandidateResolver { return DropSetCandidateResolution.Invalid(DropSetCandidateInvalidReason.BELOW_MINIMUM) } val candidateCommand = request.commandTemplate.copy(weightPerCableKg = candidateWeight) - if (WorkoutCommandValidator.validateProgramParams(candidateCommand).isFailure) { + if (WorkoutCommandValidator.validateProgramParams(candidateCommand, request.hardwareModel).isFailure) { return DropSetCandidateResolution.Invalid(DropSetCandidateInvalidReason.INVALID_COMMAND) } if (candidateWeight >= start) { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/DropSetEligibilityPolicy.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/DropSetEligibilityPolicy.kt index 1973794a5..e22305e02 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/DropSetEligibilityPolicy.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/DropSetEligibilityPolicy.kt @@ -7,6 +7,7 @@ import com.devil.phoenixproject.domain.model.DropSetEligibilityResult import com.devil.phoenixproject.domain.model.DropSetFeatureGate import com.devil.phoenixproject.domain.model.DropSetIneligibleReason import com.devil.phoenixproject.domain.model.DropSetOffer +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.RoutineExecutionIdentity import com.devil.phoenixproject.domain.model.SetEndReason @@ -19,6 +20,7 @@ internal data class DropSetEligibilityRequest( val configuration: DropSetConfiguration, val expectedLiveIdentity: RoutineExecutionIdentity?, val commandTemplate: WorkoutParameters, + val hardwareModel: PhoenixModel, ) class DropSetEligibilityPolicy( @@ -63,6 +65,7 @@ class DropSetEligibilityPolicy( programmedBaseWeightPerCableKg = completion.programmedBaseWeightPerCableKg, minimumWeightPerCableKg = minimum, commandTemplate = request.commandTemplate, + hardwareModel = request.hardwareModel, ), ) ) { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/BulkWeightAdjustDialog.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/BulkWeightAdjustDialog.kt index 33c1e9f8a..c3ff830ff 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/BulkWeightAdjustDialog.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/BulkWeightAdjustDialog.kt @@ -39,8 +39,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.RoutineExercise import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.util.ChassisLimits import com.devil.phoenixproject.util.Constants import com.devil.phoenixproject.util.UnitConverter import org.jetbrains.compose.resources.stringResource @@ -79,7 +81,7 @@ sealed class BulkAdjustMode { * (their weight is PR-derived at runtime; adjusting the absolute field would be misleading). * - [RoutineExercise.weightPerCableKg] is adjusted. * - [RoutineExercise.setWeightsPerCableKg], if non-empty, has each entry adjusted. - * - Results are clamped to [Constants.MIN_WEIGHT_KG]..[Constants.MAX_WEIGHT_PER_CABLE_KG] (110kg). + * - Results are clamped to [Constants.MIN_WEIGHT_KG]..[ChassisLimits.maxKgPerCable]. * - Results are rounded to the 0.5kg machine increment via [UnitConverter.roundToMachineIncrement]. * - All non-weight fields (id, exercise, orderIndex, etc.) are preserved unchanged. * @@ -88,6 +90,7 @@ sealed class BulkAdjustMode { fun applyBulkAdjust( exercises: List, mode: BulkAdjustMode, + hardwareModel: PhoenixModel, ): List = exercises.map { ex -> if (ex.usePercentOfPR) return@map ex @@ -97,7 +100,7 @@ fun applyBulkAdjust( is BulkAdjustMode.Absolute -> weight + mode.deltaKg } return UnitConverter.roundToMachineIncrement( - raw.coerceIn(Constants.MIN_WEIGHT_KG, Constants.MAX_WEIGHT_PER_CABLE_KG), + raw.coerceIn(Constants.MIN_WEIGHT_KG, ChassisLimits.maxKgPerCable(hardwareModel)), ) } @@ -138,6 +141,7 @@ fun applyBulkAdjust( fun BulkWeightAdjustDialog( exercises: List, weightUnit: WeightUnit, + hardwareModel: PhoenixModel, formatWeight: (Float, WeightUnit) -> String, onApply: (List) -> Unit, onDismiss: () -> Unit, @@ -178,7 +182,7 @@ fun BulkWeightAdjustDialog( // Compute preview val preview by remember(currentMode, exercises) { derivedStateOf { - currentMode?.let { mode -> applyBulkAdjust(exercises, mode) } + currentMode?.let { mode -> applyBulkAdjust(exercises, mode, hardwareModel) } } } @@ -283,6 +287,7 @@ fun BulkWeightAdjustDialog( exercise = original, newWeight = adjusted?.weightPerCableKg, weightUnit = weightUnit, + hardwareModel = hardwareModel, formatWeight = formatWeight, ) } @@ -393,12 +398,14 @@ private fun PreviewRow( exercise: RoutineExercise, newWeight: Float?, weightUnit: WeightUnit, + hardwareModel: PhoenixModel, formatWeight: (Float, WeightUnit) -> String, ) { val isPRScaled = exercise.usePercentOfPR val currentFormatted = formatWeight(exercise.weightPerCableKg, weightUnit) + val maxKg = ChassisLimits.maxKgPerCable(hardwareModel) val isClamped = newWeight != null && ( - newWeight <= Constants.MIN_WEIGHT_KG || newWeight > Constants.MAX_WEIGHT_PER_CABLE_KG + newWeight <= Constants.MIN_WEIGHT_KG || newWeight > maxKg ) val hasChanged = newWeight != null && newWeight != exercise.weightPerCableKg diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ExerciseConfigModal.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ExerciseConfigModal.kt index b0c3c56d6..46753b865 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ExerciseConfigModal.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ExerciseConfigModal.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import com.devil.phoenixproject.domain.model.EchoLevel import com.devil.phoenixproject.domain.model.ExerciseConfig +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.percentLabel import com.devil.phoenixproject.ui.theme.Spacing @@ -53,6 +54,7 @@ fun ExerciseConfigModal( templateSets: Int, templateReps: Int?, oneRepMaxKg: Float?, + hardwareModel: PhoenixModel, prWeight: Float? = null, initialConfig: ExerciseConfig, onConfirm: (ExerciseConfig) -> Unit, @@ -117,6 +119,7 @@ fun ExerciseConfigModal( ProgramMode.OldSchool -> OldSchoolConfigPanel( weight = config.weightPerCableKg, onWeightChange = { config = config.copy(weightPerCableKg = it) }, + hardwareModel = hardwareModel, prWeight = prWeight, stepKg = weightStepKg, ) @@ -124,6 +127,7 @@ fun ExerciseConfigModal( ProgramMode.TUT -> TutConfigPanel( weight = config.weightPerCableKg, onWeightChange = { config = config.copy(weightPerCableKg = it) }, + hardwareModel = hardwareModel, isBeastMode = config.mode == ProgramMode.TUTBeast, onBeastModeChange = { enabled -> config = config.copy( @@ -137,6 +141,7 @@ fun ExerciseConfigModal( ProgramMode.Pump -> PumpConfigPanel( weight = config.weightPerCableKg, onWeightChange = { config = config.copy(weightPerCableKg = it) }, + hardwareModel = hardwareModel, prWeight = prWeight, stepKg = weightStepKg, ) @@ -144,6 +149,7 @@ fun ExerciseConfigModal( ProgramMode.EccentricOnly -> EccentricConfigPanel( weight = config.weightPerCableKg, onWeightChange = { config = config.copy(weightPerCableKg = it) }, + hardwareModel = hardwareModel, eccentricPercent = config.eccentricLoadPercent, onEccentricPercentChange = { config = config.copy(eccentricLoadPercent = it) }, prWeight = prWeight, @@ -243,10 +249,17 @@ private fun MetaChip(label: String, value: String) { // ==================== MODE-SPECIFIC PANELS ==================== @Composable -private fun OldSchoolConfigPanel(weight: Float, onWeightChange: (Float) -> Unit, prWeight: Float? = null, stepKg: Float = 2.5f) { +private fun OldSchoolConfigPanel( + weight: Float, + onWeightChange: (Float) -> Unit, + hardwareModel: PhoenixModel, + prWeight: Float? = null, + stepKg: Float = 2.5f, +) { WeightStepper( weight = weight, onWeightChange = onWeightChange, + hardwareModel = hardwareModel, label = stringResource(Res.string.starting_weight), prWeight = prWeight, step = stepKg, @@ -261,12 +274,20 @@ private fun OldSchoolConfigPanel(weight: Float, onWeightChange: (Float) -> Unit, private fun TutConfigPanel( weight: Float, onWeightChange: (Float) -> Unit, + hardwareModel: PhoenixModel, isBeastMode: Boolean, onBeastModeChange: (Boolean) -> Unit, prWeight: Float? = null, stepKg: Float = 2.5f, ) { - WeightStepper(weight = weight, onWeightChange = onWeightChange, label = stringResource(Res.string.starting_weight), prWeight = prWeight, step = stepKg) + WeightStepper( + weight = weight, + onWeightChange = onWeightChange, + hardwareModel = hardwareModel, + label = stringResource(Res.string.starting_weight), + prWeight = prWeight, + step = stepKg, + ) // Beast Mode Toggle Row( @@ -322,8 +343,21 @@ private fun TutConfigPanel( } @Composable -private fun PumpConfigPanel(weight: Float, onWeightChange: (Float) -> Unit, prWeight: Float? = null, stepKg: Float = 2.5f) { - WeightStepper(weight = weight, onWeightChange = onWeightChange, label = stringResource(Res.string.starting_weight), prWeight = prWeight, step = stepKg) +private fun PumpConfigPanel( + weight: Float, + onWeightChange: (Float) -> Unit, + hardwareModel: PhoenixModel, + prWeight: Float? = null, + stepKg: Float = 2.5f, +) { + WeightStepper( + weight = weight, + onWeightChange = onWeightChange, + hardwareModel = hardwareModel, + label = stringResource(Res.string.starting_weight), + prWeight = prWeight, + step = stepKg, + ) ModeInfoCard( title = stringResource(Res.string.config_mode_pump_title), description = stringResource(Res.string.config_mode_pump_desc), @@ -334,12 +368,20 @@ private fun PumpConfigPanel(weight: Float, onWeightChange: (Float) -> Unit, prWe private fun EccentricConfigPanel( weight: Float, onWeightChange: (Float) -> Unit, + hardwareModel: PhoenixModel, eccentricPercent: Int, onEccentricPercentChange: (Int) -> Unit, prWeight: Float? = null, stepKg: Float = 2.5f, ) { - WeightStepper(weight = weight, onWeightChange = onWeightChange, label = stringResource(Res.string.starting_weight), prWeight = prWeight, step = stepKg) + WeightStepper( + weight = weight, + onWeightChange = onWeightChange, + hardwareModel = hardwareModel, + label = stringResource(Res.string.starting_weight), + prWeight = prWeight, + step = stepKg, + ) EccentricSlider( percent = eccentricPercent, onPercentChange = onEccentricPercentChange, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/WeightStepper.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/WeightStepper.kt index 7e3544cf4..290eb4613 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/WeightStepper.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/WeightStepper.kt @@ -14,9 +14,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.WeightUnit import com.devil.phoenixproject.presentation.util.WeightDisplayFormatter import com.devil.phoenixproject.ui.theme.Spacing +import com.devil.phoenixproject.util.ChassisLimits import com.devil.phoenixproject.util.format import org.jetbrains.compose.resources.stringResource import projectphoenix.shared.generated.resources.* @@ -29,8 +31,8 @@ import projectphoenix.shared.generated.resources.Res * @param weight Current weight value in kg * @param onWeightChange Callback when weight is changed * @param modifier Modifier for the composable + * @param hardwareModel Connected chassis; unknown fail-closes to 100 kg/cable * @param minWeight Minimum allowed weight (default 0) - * @param maxWeight Maximum allowed weight per cable (default 100kg) * @param step Weight increment/decrement step (default 2.5kg). * Note: WeightStepper does NOT apply [UnitConverter.roundToMachineIncrement] after stepping. * Callers that transmit the result over BLE should round to the machine's 0.5kg increment @@ -42,14 +44,15 @@ import projectphoenix.shared.generated.resources.Res fun WeightStepper( weight: Float, onWeightChange: (Float) -> Unit, + hardwareModel: PhoenixModel, modifier: Modifier = Modifier, minWeight: Float = 0f, - maxWeight: Float = 100f, // Per cable max (V-Form: 100kg, Trainer+: 110kg) step: Float = 2.5f, label: String = "Weight", prWeight: Float? = null, prPhaseLabel: String? = null, ) { + val maxWeight = ChassisLimits.maxKgPerCable(hardwareModel) Column(modifier = modifier) { // Header row with label and PR indicator Row( 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 9c78b914f..b935d2483 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 @@ -97,6 +97,7 @@ import com.devil.phoenixproject.domain.usecase.RoutineSetWeightResolver import com.devil.phoenixproject.getPlatform import com.devil.phoenixproject.util.BleConstants import com.devil.phoenixproject.util.BlePacketFactory +import com.devil.phoenixproject.util.ChassisLimits import com.devil.phoenixproject.util.Constants import com.devil.phoenixproject.util.DataBackupManager import com.devil.phoenixproject.util.KmpUtils @@ -1460,6 +1461,7 @@ class ActiveSessionEngine( configuration = dropSetConfigurationProvider(preparation.sourceExercise), expectedLiveIdentity = source.routineIdentity, commandTemplate = source.commandTemplate, + hardwareModel = chassisModel(), ), ) buildRestTransitionPlan(plan.normalAdvance, eligibility) == plan @@ -3608,6 +3610,7 @@ class ActiveSessionEngine( programmedBaseWeightPerCableKg = programmedBaseWeightPerCableKg, minimumWeightPerCableKg = minimum, commandTemplate = sourceCommandTemplate, + hardwareModel = chassisModel(), ), ) as? DropSetCandidateResolution.Valid ?: return false return sameMachineWeight(resolution.candidate.resolvedWeightPerCableKg, expectedWeightPerCableKg) && @@ -3615,6 +3618,8 @@ class ActiveSessionEngine( expectedWeightPerCableKg < sourceConfiguredStartWeightPerCableKg } + private fun chassisModel() = ChassisLimits.modelOf(bleRepository.connectionState.value) + private fun validateWorkoutCommand(params: WorkoutParameters): Result = if (params.isEchoMode) { WorkoutCommandValidator.validateEchoControl( level = params.echoLevel, @@ -3625,7 +3630,7 @@ class ActiveSessionEngine( eccentricPct = params.eccentricLoad.percentage, ) } else { - WorkoutCommandValidator.validateProgramParams(params) + WorkoutCommandValidator.validateProgramParams(params, chassisModel()) } private suspend fun failAcceptedRetryClosed( @@ -6495,15 +6500,18 @@ class ActiveSessionEngine( val params = coordinator._workoutParameters.value val command = if (!params.isEchoMode) { + val model = chassisModel() WorkoutCommandValidator.validateLegacyWorkoutCommand( params.programMode, weightKg, params.reps, + model, ).getOrThrow() BlePacketFactory.createWorkoutCommand( params.programMode, weightKg, params.reps, + model, ) } else { return @@ -6528,11 +6536,10 @@ class ActiveSessionEngine( * machine can't receive new exercise packet until active one fully ends). */ fun adjustWeight(newWeightKg: Float, sendToMachine: Boolean = true) { - // Upper bound is 110kg per cable to support both hardware variants: - // V-Form (VIT-200): 100kg max per cable - // Trainer+: 110kg max per cable - // Do NOT replace with Constants.MAX_WEIGHT_KG (100f) — that would regress Trainer+ users. - val clampedWeight = newWeightKg.coerceIn(0f, 110f) + val clampedWeight = newWeightKg.coerceIn( + Constants.MIN_WEIGHT_KG, + ChassisLimits.maxKgPerCable(chassisModel()), + ) Logger.d("ActiveSessionEngine: Adjusting weight to $clampedWeight kg (sendToMachine=$sendToMachine)") @@ -7510,7 +7517,8 @@ class ActiveSessionEngine( warmupSet: com.devil.phoenixproject.domain.model.WarmupSet, reps: Int, ): WorkoutParameters { - val warmupWeight = (workingWeightKg * warmupSet.percentOfWorking / 100f).coerceIn(0f, 110f) + val warmupWeight = (workingWeightKg * warmupSet.percentOfWorking / 100f) + .coerceIn(Constants.MIN_WEIGHT_KG, ChassisLimits.maxKgPerCable(chassisModel())) return baseParams.copy( weightPerCableKg = warmupWeight, reps = reps, @@ -8209,7 +8217,7 @@ class ActiveSessionEngine( val warmupSet = currentExercise.warmupSets.getOrNull(warmupSetIndex) if (warmupSet != null) { val warmupWeight = (effectiveParams.weightPerCableKg * warmupSet.percentOfWorking / 100f) - .coerceIn(0f, 110f) + .coerceIn(Constants.MIN_WEIGHT_KG, ChassisLimits.maxKgPerCable(chassisModel())) Logger.d { "WarmupSet ${warmupSetIndex + 1}/${currentExercise.warmupSets.size}: " + "${warmupSet.reps} reps @ ${warmupSet.percentOfWorking}% = ${warmupWeight}kg (working=${effectiveParams.weightPerCableKg}kg)" @@ -8348,7 +8356,7 @@ class ActiveSessionEngine( eccentricPct = bleParams.eccentricLoad.percentage, ) } else { - WorkoutCommandValidator.validateProgramParams(bleParams) + WorkoutCommandValidator.validateProgramParams(bleParams, chassisModel()) } commandValidation.onFailure { error -> Logger.e(error) { "Invalid BLE workout command parameters: ${error.message}" } @@ -8371,7 +8379,7 @@ class ActiveSessionEngine( eccentricPct = bleParams.eccentricLoad.percentage, ) } else { - BlePacketFactory.createProgramParams(bleParams) + BlePacketFactory.createProgramParams(bleParams, chassisModel()) } Logger.d { "Built ${command.size}-byte workout command for ${bleParams.programMode}" } @@ -12314,6 +12322,7 @@ class ActiveSessionEngine( configuration = dropSetConfigurationProvider(sourceExercise), expectedLiveIdentity = identity, commandTemplate = completion.logicalPreRackCommandTemplate, + hardwareModel = chassisModel(), ), ) val prior = activeRuntimeDocument?.takeIf { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt index bad6adf40..d46209189 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt @@ -73,6 +73,7 @@ import com.devil.phoenixproject.data.repository.VelocityOneRepMaxRepository import com.devil.phoenixproject.domain.model.EccentricLoad import com.devil.phoenixproject.domain.model.EchoLevel import com.devil.phoenixproject.domain.model.PersonalRecord +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.RackItem import com.devil.phoenixproject.domain.model.RepCountTiming import com.devil.phoenixproject.domain.model.RoutineExercise @@ -92,6 +93,7 @@ import com.devil.phoenixproject.presentation.viewmodel.ExerciseType import com.devil.phoenixproject.presentation.viewmodel.SetConfiguration import com.devil.phoenixproject.presentation.viewmodel.SetMode import com.devil.phoenixproject.ui.theme.Spacing +import com.devil.phoenixproject.util.ChassisLimits import com.devil.phoenixproject.util.parseLocalizedDecimal import kotlin.math.roundToInt import kotlinx.coroutines.launch @@ -119,6 +121,7 @@ import projectphoenix.shared.generated.resources.percent_label fun ExerciseEditBottomSheet( exercise: RoutineExercise, weightUnit: WeightUnit, + hardwareModel: PhoenixModel, enableVideoPlayback: Boolean, kgToDisplay: (Float, WeightUnit) -> Float, displayToKg: (Float, WeightUnit) -> Float, @@ -233,7 +236,7 @@ fun ExerciseEditBottomSheet( sharedBaselineKg != null val weightSuffix = if (weightUnit == WeightUnit.LB) "lbs" else "kg" - val maxWeight = if (weightUnit == WeightUnit.LB) 242f else 110f // 110kg per cable max + val maxWeight = ChassisLimits.maxDisplay(hardwareModel, weightUnit) // Issue #266/#410: Use configured increment if provided, otherwise default for unit val weightStep = if (weightStepOverride > 0f) { kgToDisplay(weightStepOverride, weightUnit) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt index ee8594fd6..21de22d06 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt @@ -111,6 +111,7 @@ import com.devil.phoenixproject.presentation.viewmodel.MainViewModel import com.devil.phoenixproject.ui.theme.AccessibilityTheme import com.devil.phoenixproject.ui.theme.Spacing import com.devil.phoenixproject.ui.theme.ThemeMode +import com.devil.phoenixproject.util.ChassisLimits import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import projectphoenix.shared.generated.resources.Res @@ -148,6 +149,8 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme val autoStopState by viewModel.autoStopState.collectAsState() val weightUnit by viewModel.weightUnit.collectAsState() val userPreferences by viewModel.userPreferences.collectAsState() + val connectionState by viewModel.connectionState.collectAsState() + val hardwareModel = ChassisLimits.modelOf(connectionState) val connectionError by viewModel.connectionError.collectAsState() @@ -224,9 +227,7 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme } } - // Enable handle detection for auto-start when connected - val connectionState by viewModel.connectionState.collectAsState() - + // Enable handle detection for auto-start when connected. // Single consolidated effect for handle detection (Issue: iOS autostart race condition fix) // Previously had two effects (Unit + connectionState) that could both fire and reset // the state machine mid-grab on iOS due to different recomposition timing. @@ -499,7 +500,7 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme verticalArrangement = Arrangement.spacedBy(Spacing.small), ) { val weightSuffix = if (weightUnit == WeightUnit.LB) "lbs" else "kg" - val maxWeight = if (weightUnit == WeightUnit.LB) 242f else 110f + val maxWeight = ChassisLimits.maxDisplay(hardwareModel, weightUnit) val weightStep = viewModel.kgToDisplay(userPreferences.effectiveWeightIncrementKg, weightUnit) val displayWeight = viewModel.kgToDisplay(weightPerCable, weightUnit) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ModeConfirmationScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ModeConfirmationScreen.kt index 85fe4382c..36b30176a 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ModeConfirmationScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ModeConfirmationScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.devil.phoenixproject.domain.model.CycleTemplate import com.devil.phoenixproject.domain.model.ExerciseConfig +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.TemplateExercise import com.devil.phoenixproject.domain.model.WeightUnit @@ -51,6 +52,7 @@ fun ModeConfirmationScreen( kgToDisplay: (Float, WeightUnit) -> Float = { kg, _ -> kg }, onConfirm: (Map) -> Unit, onCancel: () -> Unit, + hardwareModel: PhoenixModel, weightStepKg: Float = 2.5f, // Issue #266: Configurable weight step in kg ) { // State: Map of exercise name to ExerciseConfig @@ -202,6 +204,7 @@ fun ModeConfirmationScreen( newConfig.weightPerCableKg != displayedConfig.weightPerCableKg, ) }, + hardwareModel = hardwareModel, weightStepKg = weightStepKg, ) } @@ -271,6 +274,7 @@ private fun ConfigurableExerciseCard( weightUnit: WeightUnit = WeightUnit.KG, kgToDisplay: (Float, WeightUnit) -> Float = { kg, _ -> kg }, onConfigUpdated: (ExerciseConfig) -> Unit, + hardwareModel: PhoenixModel, weightStepKg: Float = 2.5f, // Issue #266 ) { var showConfigModal by remember { mutableStateOf(false) } @@ -361,6 +365,7 @@ private fun ConfigurableExerciseCard( templateSets = exercise.sets, templateReps = exercise.reps, oneRepMaxKg = oneRepMaxKg, + hardwareModel = hardwareModel, prWeight = prWeight, initialConfig = config, onConfirm = { newConfig -> diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RestTimerCard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RestTimerCard.kt index 19c04e077..92551bd66 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RestTimerCard.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RestTimerCard.kt @@ -64,6 +64,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.devil.phoenixproject.domain.model.DropPercentage import com.devil.phoenixproject.domain.model.EchoLevel +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.WeightUnit import com.devil.phoenixproject.domain.model.percentLabel @@ -74,7 +75,7 @@ import com.devil.phoenixproject.presentation.components.WeightChangePerRepContro import com.devil.phoenixproject.presentation.manager.RestActionIdentity import com.devil.phoenixproject.ui.theme.Spacing import com.devil.phoenixproject.ui.theme.screenBackgroundBrush -import com.devil.phoenixproject.util.Constants +import com.devil.phoenixproject.util.ChassisLimits import org.jetbrains.compose.resources.stringResource import projectphoenix.shared.generated.resources.Res import projectphoenix.shared.generated.resources.cd_add_30_seconds @@ -168,6 +169,7 @@ fun RestTimerCard( isNextExerciseBodyweight: Boolean = false, // Issue #266/#410: Configurable weight step from user preferences weightStepKg: Float = 0.25f, + hardwareModel: PhoenixModel, dropSetOffer: DropSetOfferUiState? = null, onAcceptDropSet: (RestActionIdentity, DropPercentage) -> Unit = { _, _ -> }, onDeclineDropSet: (RestActionIdentity) -> Unit = {}, @@ -533,7 +535,7 @@ fun RestTimerCard( } else { // Non-Echo modes: Show weight adjuster if (nextExerciseWeight != null && formatWeightWithUnit != null) { - val maxWeightKg = Constants.MAX_WEIGHT_PER_CABLE_KG + val maxWeightKg = ChassisLimits.maxKgPerCable(hardwareModel) // Issue #266/#410: weightStepKg now comes from parameter // Delta from baseline (nextExerciseWeight is the routine-configured weight in kg) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineEditorScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineEditorScreen.kt index f315f67d3..434be346d 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineEditorScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineEditorScreen.kt @@ -86,6 +86,7 @@ import com.devil.phoenixproject.presentation.components.SupersetHeader import com.devil.phoenixproject.presentation.components.SupersetPickerDialog import com.devil.phoenixproject.presentation.routine.buildDefaultRoutineExerciseForEditor import com.devil.phoenixproject.ui.theme.SupersetTheme +import com.devil.phoenixproject.util.ChassisLimits import com.devil.phoenixproject.util.UnitConverter import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject @@ -137,6 +138,8 @@ fun RoutineEditorScreen( ) { // Issue #266/#410: Get user preferences for weight increment val userPreferences by viewModel.userPreferences.collectAsState() + val connectionState by viewModel.connectionState.collectAsState() + val hardwareModel = ChassisLimits.modelOf(connectionState) val rackItems by viewModel.rackItems.collectAsState() val activeProfileId by viewModel.activeProfileId.collectAsState() val completedExerciseIdsState by viewModel.completedExerciseIdsState.collectAsState() @@ -868,6 +871,7 @@ fun RoutineEditorScreen( ExerciseEditBottomSheet( exercise = exercise, weightUnit = weightUnit, + hardwareModel = hardwareModel, enableVideoPlayback = enableVideoPlayback, kgToDisplay = kgToDisplay, displayToKg = displayToKg, @@ -1052,6 +1056,7 @@ fun RoutineEditorScreen( BulkWeightAdjustDialog( exercises = targetExercises, weightUnit = weightUnit, + hardwareModel = hardwareModel, formatWeight = { weight, unit -> val displayWeight = kgToDisplay(weight, unit) if (unit == WeightUnit.LB) "${UnitConverter.formatDecimal(displayWeight)} lbs" else "${UnitConverter.formatDecimal(displayWeight)} kg" diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineOverviewScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineOverviewScreen.kt index 91926ad9e..be4c970dd 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineOverviewScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineOverviewScreen.kt @@ -72,6 +72,7 @@ import com.devil.phoenixproject.data.repository.ExerciseImageEntity import com.devil.phoenixproject.data.repository.ExerciseRepository import com.devil.phoenixproject.domain.model.ConnectionState import com.devil.phoenixproject.domain.model.EchoLevel +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.RoutineExercise import com.devil.phoenixproject.domain.model.RoutineFlowState @@ -96,7 +97,7 @@ import com.devil.phoenixproject.presentation.viewmodel.MainViewModel import com.devil.phoenixproject.ui.theme.Spacing import com.devil.phoenixproject.ui.theme.labelAllCaps import com.devil.phoenixproject.ui.theme.labelSmallAllCaps -import com.devil.phoenixproject.util.Constants +import com.devil.phoenixproject.util.ChassisLimits import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import projectphoenix.shared.generated.resources.Res @@ -337,6 +338,7 @@ fun RoutineOverviewScreen(navController: NavController, viewModel: MainViewModel eccentricLoadPercent = adjustments.eccentricLoadPercent, sizing = overviewSizing, weightStepKg = userPreferences.effectiveWeightIncrementKg, // Issue #266/#410 + hardwareModel = ChassisLimits.modelOf(connectionState), onWeightChange = { newWeight -> if (newWeight >= 0f) { adjustmentState.value = adjustmentState.value.copy(weight = newWeight) @@ -556,12 +558,13 @@ private fun ExerciseOverviewCard( eccentricLoadPercent: Int, sizing: RoutineOverviewSizing, weightStepKg: Float = 0.25f, // Issue #266/#410: Configurable weight step + hardwareModel: PhoenixModel, onWeightChange: (Float) -> Unit, onRepsChange: (Int) -> Unit, onEchoLevelChange: (EchoLevel) -> Unit, onEccentricLoadChange: (Int) -> Unit, ) { - val maxWeightKg = Constants.MAX_WEIGHT_PER_CABLE_KG + val maxWeightKg = ChassisLimits.maxKgPerCable(hardwareModel) // #635: explicit stored flag with equipment-derivation fallback val isBodyweight = exercise.exercise.isBodyweight diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SetReadyScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SetReadyScreen.kt index c9f92a67e..2d71ecf95 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SetReadyScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SetReadyScreen.kt @@ -96,7 +96,7 @@ import com.devil.phoenixproject.ui.theme.Spacing import com.devil.phoenixproject.ui.theme.labelAllCaps import com.devil.phoenixproject.ui.theme.labelSmallAllCaps import com.devil.phoenixproject.ui.theme.screenBackgroundBrush -import com.devil.phoenixproject.util.Constants +import com.devil.phoenixproject.util.ChassisLimits import com.devil.phoenixproject.util.UnitConverter import org.jetbrains.compose.resources.stringResource import projectphoenix.shared.generated.resources.Res @@ -187,7 +187,7 @@ fun SetReadyScreen(navController: NavController, viewModel: MainViewModel, exerc val resolvedBodyWeightKg = sessionBodyweightState.sessionBodyWeightKg ?: userPreferences.bodyWeightKg val bodyweightPromptPending = sessionBodyweightState.routineHasBodyweight && !sessionBodyweightState.promptHandled - val maxWeightKg = Constants.MAX_WEIGHT_PER_CABLE_KG + val maxWeightKg = ChassisLimits.maxKgPerCable(ChassisLimits.modelOf(connectionState)) val weightStepKg = userPreferences.effectiveWeightIncrementKg // Navigation state - uses superset-aware helpers from ViewModel diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SingleExerciseScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SingleExerciseScreen.kt index 01968d5fc..651240c7a 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SingleExerciseScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SingleExerciseScreen.kt @@ -48,6 +48,7 @@ import com.devil.phoenixproject.presentation.manager.DefaultWorkoutSessionManage import com.devil.phoenixproject.presentation.navigation.NavigationRoutes import com.devil.phoenixproject.presentation.viewmodel.MainViewModel import com.devil.phoenixproject.ui.theme.ThemeMode +import com.devil.phoenixproject.util.ChassisLimits import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString @@ -76,6 +77,8 @@ fun SingleExerciseScreen( val activeProfileId by viewModel.activeProfileId.collectAsState() val completedExerciseIdsState by viewModel.completedExerciseIdsState.collectAsState() val machineTeardownState by viewModel.machineTeardownState.collectAsState() + val connectionState by viewModel.connectionState.collectAsState() + val hardwareModel = ChassisLimits.modelOf(connectionState) val pickerCompletedExerciseIds = completedExerciseIdsState.ids.takeIf { completedExerciseIdsState.profileId == activeProfileId } ?: emptySet() @@ -350,6 +353,7 @@ fun SingleExerciseScreen( ExerciseEditBottomSheet( exercise = routineExercise, weightUnit = weightUnit, + hardwareModel = hardwareModel, enableVideoPlayback = enableVideoPlayback, kgToDisplay = viewModel::kgToDisplay, displayToKg = viewModel::displayToKg, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/TrainingCyclesScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/TrainingCyclesScreen.kt index a101fc52c..0c3f88e16 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/TrainingCyclesScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/TrainingCyclesScreen.kt @@ -98,6 +98,7 @@ import com.devil.phoenixproject.presentation.manager.RoutineResumeHandle import com.devil.phoenixproject.presentation.navigation.NavigationRoutes import com.devil.phoenixproject.presentation.util.LocalPlatformAccessibilitySettings import com.devil.phoenixproject.presentation.viewmodel.MainViewModel +import com.devil.phoenixproject.util.ChassisLimits import com.devil.phoenixproject.presentation.viewmodel.RoutineResumeActionAuthority import com.devil.phoenixproject.presentation.viewmodel.RoutineResumeCompletionDisposition import com.devil.phoenixproject.presentation.viewmodel.RoutineResumeEntryPoint @@ -185,6 +186,8 @@ fun TrainingCyclesScreen(navController: NavController, viewModel: MainViewModel, // User preferences for weight unit and increment val weightUnit by viewModel.weightUnit.collectAsState() val userPreferences by viewModel.userPreferences.collectAsState() + val connectionState by viewModel.connectionState.collectAsState() + val hardwareModel = ChassisLimits.modelOf(connectionState) // Collect cycles from repository val cycles by cycleRepository.getAllCycles(profileId).collectAsState(initial = emptyList()) @@ -755,6 +758,7 @@ fun TrainingCyclesScreen(navController: NavController, viewModel: MainViewModel, prWeightValues = state.prWeightValues, weightUnit = weightUnit, kgToDisplay = viewModel::kgToDisplay, + hardwareModel = hardwareModel, weightStepKg = userPreferences.effectiveWeightIncrementKg, onConfirm = { exerciseConfigs -> creationState = CycleCreationState.Creating(state.template) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt index 2068e5f2f..f32d9d460 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt @@ -111,6 +111,7 @@ import com.devil.phoenixproject.presentation.util.WindowWidthSizeClass import com.devil.phoenixproject.ui.theme.ExpressiveMotion import com.devil.phoenixproject.ui.theme.Spacing import com.devil.phoenixproject.ui.theme.screenBackgroundBrush +import com.devil.phoenixproject.util.ChassisLimits import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -679,6 +680,7 @@ fun WorkoutTab( formatWeight = { weight -> formatWeight(weight, weightUnit) }, formatWeightWithUnit = formatWeight, weightStepKg = weightStepKg, // Issue #266/#410 + hardwareModel = ChassisLimits.modelOf(connectionState), isSupersetTransition = workoutState.isSupersetTransition, supersetLabel = workoutState.supersetLabel, isRestPaused = isRestPaused, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt index 63702bd20..cc2a38e5e 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt @@ -67,7 +67,7 @@ object BleConstants { * - 0x40-0x4F: eccentric activation phase * - 0x48-0x4F: eccentric-up ramp inside the eccentric activation phase * - 0x50-0x53: forceMin (0.0f) - * - 0x54-0x57: forceMax (adjustedWeight + 10.0f — force ceiling) + * - 0x54-0x57: forceMax (min(adjustedWeight + 10.0f, chassisMax) — force ceiling) * - 0x58-0x5B: target weight (adjustedWeight — actual operating weight) * - 0x5C-0x5F: progression (progressionRegressionKg) * @@ -93,7 +93,7 @@ object BleConstants { // Force config block const val OFFSET_FORCE_MIN = 0x50 // 0.0f in activation packets - const val OFFSET_FORCE_MAX = 0x54 // adjustedWeight + 10.0f (force ceiling) + const val OFFSET_FORCE_MAX = 0x54 // min(adjustedWeight + 10.0f, chassisMax) const val OFFSET_TARGET_WEIGHT = 0x58 // adjustedWeight (actual operating weight) const val OFFSET_PROGRESSION = 0x5C // progressionRegressionKg } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt index c3bef52b3..4f8bc8fa9 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt @@ -2,6 +2,7 @@ package com.devil.phoenixproject.util import co.touchlab.kermit.Logger import com.devil.phoenixproject.domain.model.EchoLevel +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.WorkoutParameters import kotlin.concurrent.Volatile @@ -98,8 +99,14 @@ object BlePacketFactory { * Creates a simplified workout command for backward compatibility. * For full protocol support, use createProgramParams() instead. */ - fun createWorkoutCommand(programMode: ProgramMode, weightPerCableKg: Float, targetReps: Int): ByteArray { - WorkoutCommandValidator.validateLegacyWorkoutCommand(programMode, weightPerCableKg, targetReps).getOrThrow() + fun createWorkoutCommand( + programMode: ProgramMode, + weightPerCableKg: Float, + targetReps: Int, + model: PhoenixModel, + ): ByteArray { + WorkoutCommandValidator.validateLegacyWorkoutCommand(programMode, weightPerCableKg, targetReps, model) + .getOrThrow() val buffer = ByteArray(25) buffer[0] = BleConstants.Commands.REGULAR_COMMAND @@ -127,8 +134,12 @@ object BlePacketFactory { * [ForceConfigVariant.OVERLAP] is retained only to reproduce the legacy Phoenix * behavior that overwrote 0x48/0x4C after copying the profile. */ - fun createProgramParams(params: WorkoutParameters, variant: ForceConfigVariant = defaultForceConfigVariant): ByteArray { - WorkoutCommandValidator.validateProgramParams(params).getOrThrow() + fun createProgramParams( + params: WorkoutParameters, + model: PhoenixModel, + variant: ForceConfigVariant = defaultForceConfigVariant, + ): ByteArray { + WorkoutCommandValidator.validateProgramParams(params, model).getOrThrow() // Resolve the profile up front so the variant decision can key off it. val profileMode = if (params.isEchoMode) { @@ -214,9 +225,10 @@ object BlePacketFactory { // The activation force config block keeps the selected force separate // from per-rep progression. The increment field controls progression; - // targetWeight and forceMax stay anchored to the selected force. + // targetWeight stays at the selected force; forceMax is selected + 10 + // capped at the chassis ceiling so V-Form never sees 110 kg. val targetWeightPerCable = params.weightPerCableKg - val effectiveKg = targetWeightPerCable + 10.0f + val effectiveKg = ChassisLimits.forceMaxKg(targetWeightPerCable, model) // Normal force modes keep softMax tied to the selected force // per cable. Unlimited-rep behavior is controlled by the reps field @@ -329,6 +341,9 @@ object BlePacketFactory { /** * Build Echo mode control frame (32 bytes) with full parameters. * + * Echo 0x4E is timing/profile only — kilograms are not encoded here. Selected + * force lives on the 0x04 CONFIG packet (and is not sent for Echo sessions). + * * @param eccentricPct Eccentric load percentage (0-150%). Values outside this range * are clamped for safety - machine hardware limit is 150%. */ diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/ChassisLimits.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/ChassisLimits.kt new file mode 100644 index 000000000..242dbc26f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/ChassisLimits.kt @@ -0,0 +1,42 @@ +package com.devil.phoenixproject.util + +import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.domain.model.PhoenixModel +import com.devil.phoenixproject.domain.model.WeightUnit +import kotlin.math.floor +import kotlin.math.min + +/** + * Per-cable chassis limits. Unknown hardware fail-closes to V-Form (100 kg/cable). + * + * CONFIG forceMax is [forceMaxKg] = min(selected + 10, chassisMax). Echo 0x4E does + * not carry kilograms — only the 0x04 CONFIG floats are clamped. + */ +object ChassisLimits { + const val V_FORM_KG_PER_CABLE = 100f + const val TRAINER_PLUS_KG_PER_CABLE = 110f + const val UNKNOWN_FAIL_CLOSED_KG_PER_CABLE = V_FORM_KG_PER_CABLE + const val FORCE_MAX_HEADROOM_KG = 10f + + fun modelOf(state: ConnectionState): PhoenixModel = + (state as? ConnectionState.Connected)?.hardwareModel ?: PhoenixModel.Unknown + + fun maxKgPerCable(model: PhoenixModel): Float = when (model) { + PhoenixModel.TrainerPlus -> TRAINER_PLUS_KG_PER_CABLE + PhoenixModel.VFormTrainer, + PhoenixModel.Unknown, + -> UNKNOWN_FAIL_CLOSED_KG_PER_CABLE + } + + fun forceMaxKg(weightPerCableKg: Float, model: PhoenixModel): Float = + min(weightPerCableKg + FORCE_MAX_HEADROOM_KG, maxKgPerCable(model)) + + fun maxDisplay(model: PhoenixModel, unit: WeightUnit): Float { + val kg = maxKgPerCable(model) + return if (unit == WeightUnit.LB) { + floor(UnitConverter.kgToLb(kg)) + } else { + kg + } + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/Constants.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/Constants.kt index 8c7eeeaca..458889d1c 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/Constants.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/Constants.kt @@ -11,14 +11,12 @@ object Constants { // Users must re-accept when this version increases const val EULA_VERSION = 1 - // Weight limits (in kg) - per cable, not total - // V-Form Trainer: 100kg max per cable (200kg total) - // Trainer+: 110kg max per cable (220kg total) - use 100kg as safe default + // Weight limits (in kg) - per cable, not total. + // Model-aware send/slider caps live in [ChassisLimits]; these aliases are + // the V-Form fail-closed floor and the Trainer+ absolute ceiling. const val MIN_WEIGHT_KG = 0f - const val MAX_WEIGHT_KG = 100f - - // Trainer+ hardware ceiling — used by UI sliders to enforce absolute maximum - const val MAX_WEIGHT_PER_CABLE_KG = 110f + const val MAX_WEIGHT_KG = ChassisLimits.V_FORM_KG_PER_CABLE + const val MAX_WEIGHT_PER_CABLE_KG = ChassisLimits.TRAINER_PLUS_KG_PER_CABLE // Configurable weight increment options per unit system (Issue #266) val WEIGHT_INCREMENT_OPTIONS_KG = listOf(0.5f, 1.0f, 2.5f, 5.0f) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidator.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidator.kt index 0cab8d941..304033650 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidator.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidator.kt @@ -1,6 +1,7 @@ package com.devil.phoenixproject.util import com.devil.phoenixproject.domain.model.EchoLevel +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.WorkoutParameters @@ -18,9 +19,11 @@ object WorkoutCommandValidator { programMode: ProgramMode, weightPerCableKg: Float, targetReps: Int, + model: PhoenixModel, ): Result { validateFiniteWeight(weightPerCableKg).onFailure { return Result.failure(it) } - validateWeightRange(weightPerCableKg, allowZero = false).onFailure { return Result.failure(it) } + validateWeightRange(weightPerCableKg, allowZero = false, model = model) + .onFailure { return Result.failure(it) } validateRepByte("targetReps", targetReps, allowZero = false).onFailure { return Result.failure(it) } if (programMode == ProgramMode.Echo) { return failure("Legacy workout command must not be used for Echo mode") @@ -28,7 +31,7 @@ object WorkoutCommandValidator { return Result.success(Unit) } - fun validateProgramParams(params: WorkoutParameters): Result { + fun validateProgramParams(params: WorkoutParameters, model: PhoenixModel): Result { if (params.isEchoMode) { return failure("Program parameter packet must not be used for Echo mode") } @@ -44,6 +47,7 @@ object WorkoutCommandValidator { validateWeightRange( params.weightPerCableKg, allowZero = params.isAMRAP && !params.isJustLift, + model = model, ).onFailure { return Result.failure(it) } validateRepByte("warmupReps", params.warmupReps, allowZero = true) @@ -99,13 +103,18 @@ object WorkoutCommandValidator { private fun isFinite(value: Float): Boolean = !value.isNaN() && !value.isInfinite() - private fun validateWeightRange(weightPerCableKg: Float, allowZero: Boolean): Result { + private fun validateWeightRange( + weightPerCableKg: Float, + allowZero: Boolean, + model: PhoenixModel, + ): Result { + val maxKg = ChassisLimits.maxKgPerCable(model) if (!allowZero && weightPerCableKg <= Constants.MIN_WEIGHT_KG) { return failure("weightPerCableKg must be greater than ${Constants.MIN_WEIGHT_KG}kg, got $weightPerCableKg") } - if (weightPerCableKg < Constants.MIN_WEIGHT_KG || weightPerCableKg > Constants.MAX_WEIGHT_PER_CABLE_KG) { + if (weightPerCableKg < Constants.MIN_WEIGHT_KG || weightPerCableKg > maxKg) { return failure( - "weightPerCableKg must be ${Constants.MIN_WEIGHT_KG}..${Constants.MAX_WEIGHT_PER_CABLE_KG}kg, got $weightPerCableKg", + "weightPerCableKg must be ${Constants.MIN_WEIGHT_KG}..${maxKg}kg, got $weightPerCableKg", ) } return Result.success(Unit) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/di/DropSetProductionBindingTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/di/DropSetProductionBindingTest.kt index d0aef6001..2d6dbf0d5 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/di/DropSetProductionBindingTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/di/DropSetProductionBindingTest.kt @@ -44,6 +44,7 @@ class DropSetProductionBindingTest { configuration = configuration, expectedLiveIdentity = null, commandTemplate = WorkoutParameters(ProgramMode.OldSchool, reps = 8, weightPerCableKg = 20f), + hardwareModel = com.devil.phoenixproject.domain.model.PhoenixModel.TrainerPlus, ), ) assertEquals( diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/DropSetCandidateResolverTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/DropSetCandidateResolverTest.kt index 93659dffe..18ffc985b 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/DropSetCandidateResolverTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/DropSetCandidateResolverTest.kt @@ -229,6 +229,7 @@ class DropSetCandidateResolverTest { programmedBaseWeightPerCableKg = base, minimumWeightPerCableKg = floor, commandTemplate = template, + hardwareModel = com.devil.phoenixproject.domain.model.PhoenixModel.TrainerPlus, ), ) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/DropSetEligibilityPolicyTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/DropSetEligibilityPolicyTest.kt index c7893bdb6..06890c29f 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/DropSetEligibilityPolicyTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/DropSetEligibilityPolicyTest.kt @@ -177,7 +177,14 @@ class DropSetEligibilityPolicyTest { configuration: DropSetConfiguration = DropSetConfiguration(true, 1f), expectedLiveIdentity: RoutineExecutionIdentity? = identity(), commandTemplate: WorkoutParameters = commandTemplate(), - ) = DropSetEligibilityRequest(offerId, completion, configuration, expectedLiveIdentity, commandTemplate) + ) = DropSetEligibilityRequest( + offerId, + completion, + configuration, + expectedLiveIdentity, + commandTemplate, + com.devil.phoenixproject.domain.model.PhoenixModel.TrainerPlus, + ) private fun completion( reason: SetEndReason = SetEndReason.STALL_FAILURE, diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/BulkWeightAdjustTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/BulkWeightAdjustTest.kt index 172b67c3d..822108b71 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/BulkWeightAdjustTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/BulkWeightAdjustTest.kt @@ -1,8 +1,10 @@ package com.devil.phoenixproject.presentation.components import com.devil.phoenixproject.domain.model.Exercise +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.RoutineExercise +import com.devil.phoenixproject.util.ChassisLimits import com.devil.phoenixproject.util.Constants import com.devil.phoenixproject.util.UnitConverter import kotlin.math.abs @@ -16,7 +18,7 @@ import kotlin.test.assertTrue * Covers: * - Percentage mode (positive, negative, zero) * - Absolute mode (positive, negative, zero) - * - Clamping to [0, MAX_WEIGHT_PER_CABLE_KG] (110kg Trainer+ ceiling) + * - Clamping to chassis max (100kg V-Form/unknown fail-closed, 110kg Trainer+) * - Rounding to 0.5kg machine increment * - PR-scaled exercise skipping * - Per-set weight adjustment @@ -46,19 +48,25 @@ class BulkWeightAdjustTest { programMode = ProgramMode.OldSchool, ) + private fun bulkAdjust( + exercises: List, + mode: BulkAdjustMode, + model: PhoenixModel = PhoenixModel.TrainerPlus, + ) = applyBulkAdjust(exercises, mode, model) + // ── Percentage mode ───────────────────────────────────────────── @Test fun percentage_positiveTenPercent_increasesWeight() { val exercises = listOf(exercise(weightKg = 50f)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Percentage(10f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Percentage(10f)) assertEquals(55f, result[0].weightPerCableKg) } @Test fun percentage_negativeFivePercent_decreasesWeight() { val exercises = listOf(exercise(weightKg = 40f)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Percentage(-5f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Percentage(-5f)) // 40 * 0.95 = 38.0 assertEquals(38f, result[0].weightPerCableKg) } @@ -66,7 +74,7 @@ class BulkWeightAdjustTest { @Test fun percentage_zeroPercent_noChange() { val exercises = listOf(exercise(weightKg = 50f)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Percentage(0f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Percentage(0f)) assertEquals(50f, result[0].weightPerCableKg) } @@ -74,7 +82,7 @@ class BulkWeightAdjustTest { fun percentage_roundsToHalfKg() { // 33 * 1.10 = 36.3 -> rounds to 36.5 val exercises = listOf(exercise(weightKg = 33f)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Percentage(10f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Percentage(10f)) assertEquals(36.5f, result[0].weightPerCableKg) } @@ -82,7 +90,7 @@ class BulkWeightAdjustTest { fun percentage_roundsDownToHalfKg() { // 33 * 1.05 = 34.65 -> rounds to 34.5 val exercises = listOf(exercise(weightKg = 33f)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Percentage(5f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Percentage(5f)) assertEquals(34.5f, result[0].weightPerCableKg) } @@ -91,21 +99,21 @@ class BulkWeightAdjustTest { @Test fun absolute_positiveDelta_increasesWeight() { val exercises = listOf(exercise(weightKg = 50f)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(5f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(5f)) assertEquals(55f, result[0].weightPerCableKg) } @Test fun absolute_negativeDelta_decreasesWeight() { val exercises = listOf(exercise(weightKg = 50f)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(-5f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(-5f)) assertEquals(45f, result[0].weightPerCableKg) } @Test fun absolute_zeroDelta_noChange() { val exercises = listOf(exercise(weightKg = 50f)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(0f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(0f)) assertEquals(50f, result[0].weightPerCableKg) } @@ -113,39 +121,55 @@ class BulkWeightAdjustTest { fun absolute_roundsToMachineIncrement() { // 50 + 1.3 = 51.3 -> rounds to 51.5 val exercises = listOf(exercise(weightKg = 50f)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(1.3f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(1.3f)) assertEquals(51.5f, result[0].weightPerCableKg) } // ── Clamping ──────────────────────────────────────────────────── @Test - fun clamps_toMaxWeight() { + fun clamps_toTrainerPlusMax() { val exercises = listOf(exercise(weightKg = 105f)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(10f)) - assertEquals(Constants.MAX_WEIGHT_PER_CABLE_KG, result[0].weightPerCableKg) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(10f), PhoenixModel.TrainerPlus) + assertEquals(ChassisLimits.TRAINER_PLUS_KG_PER_CABLE, result[0].weightPerCableKg) + } + + @Test + fun clamps_toVFormMax_notAlways110() { + val exercises = listOf(exercise(weightKg = 95f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(10f), PhoenixModel.VFormTrainer) + assertEquals(ChassisLimits.V_FORM_KG_PER_CABLE, result[0].weightPerCableKg) + } + + @Test + fun unknownModel_failClosesToVFormMax() { + val exercises = listOf(exercise(weightKg = 95f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(20f), PhoenixModel.Unknown) + assertEquals(ChassisLimits.UNKNOWN_FAIL_CLOSED_KG_PER_CABLE, result[0].weightPerCableKg) } @Test fun clamps_toMinWeight() { val exercises = listOf(exercise(weightKg = 3f)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(-10f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(-10f)) assertEquals(Constants.MIN_WEIGHT_KG, result[0].weightPerCableKg) } @Test fun clamps_percentageExceedingMax() { val exercises = listOf(exercise(weightKg = 80f)) - // 80 * 1.50 = 120 -> clamped to 110 - val result = applyBulkAdjust(exercises, BulkAdjustMode.Percentage(50f)) - assertEquals(Constants.MAX_WEIGHT_PER_CABLE_KG, result[0].weightPerCableKg) + // 80 * 1.50 = 120 -> clamped to Trainer+ 110 + val result = bulkAdjust(exercises, BulkAdjustMode.Percentage(50f), PhoenixModel.TrainerPlus) + assertEquals(ChassisLimits.TRAINER_PLUS_KG_PER_CABLE, result[0].weightPerCableKg) + val vForm = bulkAdjust(exercises, BulkAdjustMode.Percentage(50f), PhoenixModel.VFormTrainer) + assertEquals(ChassisLimits.V_FORM_KG_PER_CABLE, vForm[0].weightPerCableKg) } @Test fun clamps_largeNegativePercentage() { val exercises = listOf(exercise(weightKg = 50f)) // 50 * (1 + -200/100) = 50 * -1 = -50 -> clamped to 0 - val result = applyBulkAdjust(exercises, BulkAdjustMode.Percentage(-200f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Percentage(-200f)) assertEquals(Constants.MIN_WEIGHT_KG, result[0].weightPerCableKg) } @@ -161,7 +185,7 @@ class BulkWeightAdjustTest { val normalExercise = exercise(id = "normal-ex", weightKg = 50f) val exercises = listOf(prExercise, normalExercise) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(5f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(5f)) // PR exercise unchanged assertEquals(40f, result[0].weightPerCableKg) @@ -180,7 +204,7 @@ class BulkWeightAdjustTest { setWeightsKg = listOf(45f, 50f, 55f), ), ) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Percentage(10f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Percentage(10f)) assertEquals(55f, result[0].weightPerCableKg) assertEquals(listOf(49.5f, 55f, 60.5f), result[0].setWeightsPerCableKg) } @@ -193,7 +217,7 @@ class BulkWeightAdjustTest { setWeightsKg = listOf(45f, 50f, 55f), ), ) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(2.5f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(2.5f)) assertEquals(52.5f, result[0].weightPerCableKg) assertEquals(listOf(47.5f, 52.5f, 57.5f), result[0].setWeightsPerCableKg) } @@ -201,7 +225,7 @@ class BulkWeightAdjustTest { @Test fun perSetWeights_emptyList_staysEmpty() { val exercises = listOf(exercise(weightKg = 50f, setWeightsKg = emptyList())) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(5f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(5f)) assertEquals(emptyList(), result[0].setWeightsPerCableKg) } @@ -213,12 +237,13 @@ class BulkWeightAdjustTest { setWeightsKg = listOf(102f, 105f, 108f), ), ) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(10f)) - // All clamped to 110 (Trainer+ hardware ceiling) - assertEquals(Constants.MAX_WEIGHT_PER_CABLE_KG, result[0].weightPerCableKg) - assertEquals(Constants.MAX_WEIGHT_PER_CABLE_KG, result[0].setWeightsPerCableKg[0]) - assertEquals(Constants.MAX_WEIGHT_PER_CABLE_KG, result[0].setWeightsPerCableKg[1]) - assertEquals(Constants.MAX_WEIGHT_PER_CABLE_KG, result[0].setWeightsPerCableKg[2]) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(10f), PhoenixModel.TrainerPlus) + assertEquals(ChassisLimits.TRAINER_PLUS_KG_PER_CABLE, result[0].weightPerCableKg) + assertEquals(ChassisLimits.TRAINER_PLUS_KG_PER_CABLE, result[0].setWeightsPerCableKg[0]) + assertEquals(ChassisLimits.TRAINER_PLUS_KG_PER_CABLE, result[0].setWeightsPerCableKg[1]) + assertEquals(ChassisLimits.TRAINER_PLUS_KG_PER_CABLE, result[0].setWeightsPerCableKg[2]) + val vForm = bulkAdjust(exercises, BulkAdjustMode.Absolute(10f), PhoenixModel.VFormTrainer) + assertEquals(ChassisLimits.V_FORM_KG_PER_CABLE, vForm[0].weightPerCableKg) } // ── ID and field preservation ─────────────────────────────────── @@ -239,7 +264,7 @@ class BulkWeightAdjustTest { orderInSuperset = 2, ) val exercises = listOf(original) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(5f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(5f)) assertEquals("preserve-test", result[0].id) assertEquals("Squat", result[0].exercise.name) @@ -257,7 +282,7 @@ class BulkWeightAdjustTest { exercise(id = "ex-2", weightKg = 40f), exercise(id = "ex-3", weightKg = 50f), ) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Percentage(10f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Percentage(10f)) assertEquals(listOf("ex-1", "ex-2", "ex-3"), result.map { it.id }) } @@ -268,7 +293,7 @@ class BulkWeightAdjustTest { exercise(id = "b", weightKg = 20f), exercise(id = "c", weightKg = 30f), ) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(5f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(5f)) assertEquals(3, result.size) } @@ -277,7 +302,7 @@ class BulkWeightAdjustTest { @Test fun progressionKg_notScaled() { val exercises = listOf(exercise(weightKg = 50f, progressionKg = 1.5f)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Percentage(20f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Percentage(20f)) assertEquals(1.5f, result[0].progressionKg) } @@ -285,7 +310,7 @@ class BulkWeightAdjustTest { @Test fun emptyList_returnsEmptyList() { - val result = applyBulkAdjust(emptyList(), BulkAdjustMode.Absolute(5f)) + val result = bulkAdjust(emptyList(), BulkAdjustMode.Absolute(5f)) assertTrue(result.isEmpty()) } @@ -295,7 +320,7 @@ class BulkWeightAdjustTest { exercise(id = "pr-1", weightKg = 30f, usePercentOfPR = true), exercise(id = "pr-2", weightKg = 40f, usePercentOfPR = true), ) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(10f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(10f)) assertEquals(30f, result[0].weightPerCableKg) assertEquals(40f, result[1].weightPerCableKg) } @@ -307,7 +332,7 @@ class BulkWeightAdjustTest { exercise(id = "pr", weightKg = 50f, usePercentOfPR = true), exercise(id = "normal2", weightKg = 60f), ) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(5f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(5f)) assertEquals(55f, result[0].weightPerCableKg) // adjusted assertEquals(50f, result[1].weightPerCableKg) // skipped assertEquals(65f, result[2].weightPerCableKg) // adjusted @@ -316,7 +341,7 @@ class BulkWeightAdjustTest { @Test fun weightAtZero_positiveAdjust_works() { val exercises = listOf(exercise(weightKg = 0f)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(5f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(5f)) assertEquals(5f, result[0].weightPerCableKg) } @@ -324,15 +349,21 @@ class BulkWeightAdjustTest { fun weightAtZero_percentageIncrease_staysZero() { // 0 * 1.10 = 0 — percentage of zero is still zero val exercises = listOf(exercise(weightKg = 0f)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Percentage(10f)) + val result = bulkAdjust(exercises, BulkAdjustMode.Percentage(10f)) assertEquals(Constants.MIN_WEIGHT_KG, result[0].weightPerCableKg) } @Test fun weightAtMax_percentageIncrease_staysAtMax() { - val exercises = listOf(exercise(weightKg = Constants.MAX_WEIGHT_PER_CABLE_KG)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Percentage(10f)) - assertEquals(Constants.MAX_WEIGHT_PER_CABLE_KG, result[0].weightPerCableKg) + val exercises = listOf(exercise(weightKg = ChassisLimits.TRAINER_PLUS_KG_PER_CABLE)) + val result = bulkAdjust(exercises, BulkAdjustMode.Percentage(10f), PhoenixModel.TrainerPlus) + assertEquals(ChassisLimits.TRAINER_PLUS_KG_PER_CABLE, result[0].weightPerCableKg) + val vForm = bulkAdjust( + listOf(exercise(weightKg = ChassisLimits.V_FORM_KG_PER_CABLE)), + BulkAdjustMode.Percentage(10f), + PhoenixModel.VFormTrainer, + ) + assertEquals(ChassisLimits.V_FORM_KG_PER_CABLE, vForm[0].weightPerCableKg) } // ── lb-to-kg conversion path (simulates BulkWeightAdjustDialog) ──── @@ -346,7 +377,7 @@ class BulkWeightAdjustTest { assertTrue(abs(deltaKg - 2.268f) < 0.01f, "5lb should convert to ~2.268kg, got $deltaKg") val exercises = listOf(exercise(weightKg = 50f)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(deltaKg)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(deltaKg)) // 50 + 2.268 = 52.268 -> roundToMachineIncrement -> 52.5 assertEquals(52.5f, result[0].weightPerCableKg) } @@ -360,7 +391,7 @@ class BulkWeightAdjustTest { assertTrue(abs(deltaKg - (-1.134f)) < 0.01f, "-2.5lb should convert to ~-1.134kg, got $deltaKg") val exercises = listOf(exercise(weightKg = 50f)) - val result = applyBulkAdjust(exercises, BulkAdjustMode.Absolute(deltaKg)) + val result = bulkAdjust(exercises, BulkAdjustMode.Absolute(deltaKg)) // 50 + (-1.134) = 48.866 -> roundToMachineIncrement -> 49.0 assertEquals(49f, result[0].weightPerCableKg) } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/Issue673DropSetProductFlowTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/Issue673DropSetProductFlowTest.kt index f9271d927..0111d4b7c 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/Issue673DropSetProductFlowTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/Issue673DropSetProductFlowTest.kt @@ -45,6 +45,7 @@ class Issue673DropSetProductFlowTest { configuration = DropSetConfiguration(enabled.dropSetEnabled, enabled.dropSetMinWeightKg), expectedLiveIdentity = identity, commandTemplate = WorkoutParameters(ProgramMode.OldSchool, reps = 8, weightPerCableKg = 50f), + hardwareModel = com.devil.phoenixproject.domain.model.PhoenixModel.TrainerPlus, ), ), ) @@ -61,6 +62,7 @@ class Issue673DropSetProductFlowTest { configuration = DropSetConfiguration(disabled.dropSetEnabled, disabled.dropSetMinWeightKg), expectedLiveIdentity = identity, commandTemplate = WorkoutParameters(ProgramMode.OldSchool, reps = 8, weightPerCableKg = 50f), + hardwareModel = com.devil.phoenixproject.domain.model.PhoenixModel.TrainerPlus, ), ), ).reason, 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 19c7c0ccb..9cf330612 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeBleRepository.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeBleRepository.kt @@ -11,6 +11,7 @@ import com.devil.phoenixproject.domain.model.ConnectionState import com.devil.phoenixproject.domain.model.HeuristicStatistics import com.devil.phoenixproject.domain.model.WorkoutMetric import com.devil.phoenixproject.domain.model.WorkoutParameters +import com.devil.phoenixproject.util.HardwareDetection import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -102,6 +103,7 @@ class FakeBleRepository : BleRepository { ConnectionState.Connected( deviceName = deviceName, deviceAddress = deviceAddress, + hardwareModel = HardwareDetection.detectModel(deviceName), ), ) } @@ -220,6 +222,7 @@ class FakeBleRepository : BleRepository { ConnectionState.Connected( deviceName = device.name, deviceAddress = device.address, + hardwareModel = HardwareDetection.detectModel(device.name), ), ) Result.success(Unit) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BlePacketFactoryTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BlePacketFactoryTest.kt index 1894447a7..607e1ac48 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BlePacketFactoryTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BlePacketFactoryTest.kt @@ -1,6 +1,7 @@ package com.devil.phoenixproject.util import com.devil.phoenixproject.domain.model.EchoLevel +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.WorkoutMode import com.devil.phoenixproject.domain.model.WorkoutParameters @@ -34,6 +35,12 @@ class BlePacketFactoryTest { return value.toShort() } + private fun programParams( + params: WorkoutParameters, + model: PhoenixModel = PhoenixModel.TrainerPlus, + variant: BlePacketFactory.ForceConfigVariant = BlePacketFactory.defaultForceConfigVariant, + ): ByteArray = BlePacketFactory.createProgramParams(params, model, variant) + // ========== Init Command Tests ========== @Test @@ -102,6 +109,7 @@ class BlePacketFactoryTest { programMode = ProgramMode.OldSchool, weightPerCableKg = 20f, targetReps = 10, + model = PhoenixModel.TrainerPlus, ) assertEquals(25, packet.size) @@ -116,6 +124,7 @@ class BlePacketFactoryTest { programMode = ProgramMode.Pump, weightPerCableKg = 25.5f, targetReps = 12, + model = PhoenixModel.TrainerPlus, ) val weightScaled = (25.5f * 100).toInt() @@ -133,7 +142,7 @@ class BlePacketFactoryTest { weightPerCableKg = 20f, ) - val packet = BlePacketFactory.createProgramParams( + val packet = programParams( params, variant = BlePacketFactory.ForceConfigVariant.OVERLAP, ) @@ -149,7 +158,7 @@ class BlePacketFactoryTest { weightPerCableKg = 20f, ) - val packet = BlePacketFactory.createProgramParams( + val packet = programParams( params, variant = BlePacketFactory.ForceConfigVariant.OVERLAP, ) @@ -169,7 +178,7 @@ class BlePacketFactoryTest { weightPerCableKg = 20f, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(15.toByte(), packet[0x04]) } @@ -183,7 +192,7 @@ class BlePacketFactoryTest { isJustLift = true, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(0xFF.toByte(), packet[0x04]) } @@ -197,7 +206,7 @@ class BlePacketFactoryTest { isAMRAP = true, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(0xFF.toByte(), packet[0x04]) } @@ -210,7 +219,7 @@ class BlePacketFactoryTest { weightPerCableKg = 20f, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) // Pump mode profile has non-zero values at offset 0x30 assertTrue(packet[0x30] != 0.toByte() || packet[0x31] != 0.toByte()) @@ -226,7 +235,7 @@ class BlePacketFactoryTest { weightPerCableKg = 50f, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(0.0f, readFloatLE(packet, BleConstants.ActivationPacket.OFFSET_FORCE_MIN)) } @@ -242,7 +251,7 @@ class BlePacketFactoryTest { progressionRegressionKg = progression, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(weight + 10.0f, readFloatLE(packet, BleConstants.ActivationPacket.OFFSET_FORCE_MAX)) } @@ -256,7 +265,7 @@ class BlePacketFactoryTest { weightPerCableKg = weight, ) - val packet = BlePacketFactory.createProgramParams( + val packet = programParams( params, variant = BlePacketFactory.ForceConfigVariant.OVERLAP, ) @@ -275,7 +284,7 @@ class BlePacketFactoryTest { progressionRegressionKg = progression, ) - val packet = BlePacketFactory.createProgramParams( + val packet = programParams( params, variant = BlePacketFactory.ForceConfigVariant.OVERLAP, ) @@ -300,7 +309,7 @@ class BlePacketFactoryTest { progressionRegressionKg = progression, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) // 0x58 must contain the selected target weight. Progression is carried separately at 0x5C. assertEquals(weight, readFloatLE(packet, BleConstants.ActivationPacket.OFFSET_TARGET_WEIGHT)) @@ -316,7 +325,7 @@ class BlePacketFactoryTest { isJustLift = true, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) // Critical: 0x58 must have the actual operating weight. // This bug caused the machine to apply weight+10kg instead of the set weight @@ -333,7 +342,7 @@ class BlePacketFactoryTest { isAMRAP = true, ) - val packet = BlePacketFactory.createProgramParams( + val packet = programParams( params, variant = BlePacketFactory.ForceConfigVariant.OVERLAP, ) @@ -352,7 +361,7 @@ class BlePacketFactoryTest { isJustLift = true, ) - val packet = BlePacketFactory.createProgramParams( + val packet = programParams( params, variant = BlePacketFactory.ForceConfigVariant.OVERLAP, ) @@ -376,7 +385,7 @@ class BlePacketFactoryTest { ) // Test with OVERLAP variant (legacy firmware layout where force config overlaps profile) - val packet = BlePacketFactory.createProgramParams( + val packet = programParams( params, variant = BlePacketFactory.ForceConfigVariant.OVERLAP, ) @@ -409,7 +418,7 @@ class BlePacketFactoryTest { progressionRegressionKg = 0f, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(0.0f, readFloatLE(packet, BleConstants.ActivationPacket.OFFSET_INCREMENT)) assertEquals(0.0f, readFloatLE(packet, BleConstants.ActivationPacket.OFFSET_PROGRESSION)) @@ -424,7 +433,7 @@ class BlePacketFactoryTest { progressionRegressionKg = 3f, ) - val packet = BlePacketFactory.createProgramParams( + val packet = programParams( params, variant = BlePacketFactory.ForceConfigVariant.OVERLAP, ) @@ -435,7 +444,7 @@ class BlePacketFactoryTest { // Protocol force config (0x50-0x5F) assertEquals(0.0f, readFloatLE(packet, 0x50)) // forceMin - assertEquals(50.0f, readFloatLE(packet, 0x54)) // forceMax = selected weight + 10 + assertEquals(50.0f, readFloatLE(packet, 0x54)) // forceMax = min(selected weight + 10, chassisMax) assertEquals(40.0f, readFloatLE(packet, 0x58)) // targetWeight = selected weight assertEquals(3.0f, readFloatLE(packet, 0x5C)) // progression } @@ -450,7 +459,7 @@ class BlePacketFactoryTest { progressionRegressionKg = progression, ) - val packet = BlePacketFactory.createProgramParams( + val packet = programParams( params, variant = BlePacketFactory.ForceConfigVariant.OVERLAP, ) @@ -470,7 +479,7 @@ class BlePacketFactoryTest { progressionRegressionKg = 3f, ) - val packet = BlePacketFactory.createProgramParams( + val packet = programParams( params, variant = BlePacketFactory.ForceConfigVariant.NON_OVERLAP, ) @@ -497,11 +506,11 @@ class BlePacketFactoryTest { progressionRegressionKg = 3f, ) - val nonOverlapPacket = BlePacketFactory.createProgramParams( + val nonOverlapPacket = programParams( params, variant = BlePacketFactory.ForceConfigVariant.NON_OVERLAP, ) - val overlapPacket = BlePacketFactory.createProgramParams( + val overlapPacket = programParams( params, variant = BlePacketFactory.ForceConfigVariant.OVERLAP, ) @@ -698,7 +707,7 @@ class BlePacketFactoryTest { weightPerCableKg = 20f, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(96, packet.size, "Packet size should be 96 for mode $mode") assertEquals(0x04.toByte(), packet[0], "Command should be 0x04 for mode $mode") @@ -821,7 +830,7 @@ class BlePacketFactoryTest { warmupReps = 3, weightPerCableKg = 50f, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) // Command ID assertEquals(0x04.toByte(), packet[0x00], "command byte 0") @@ -871,7 +880,7 @@ class BlePacketFactoryTest { weightPerCableKg = 50f, ) // Default production layout must preserve 0x48-0x4F as the profile tail. - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) // Concentric down ramp: C1507d(0, 20, 3.0f) assertEquals(0.toShort(), readShortLE(packet, 0x30), "conc.down.minMmS") @@ -903,7 +912,7 @@ class BlePacketFactoryTest { weightPerCableKg = weight, progressionRegressionKg = 0f, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) // Force config: force-config serialization, softMax=weight, increment=0 assertEquals(0.0f, readFloatLE(packet, 0x50), "forces.min") @@ -922,7 +931,7 @@ class BlePacketFactoryTest { warmupReps = 3, weightPerCableKg = 30f, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) // Command ID assertEquals(0x04.toByte(), packet[0x00], "command byte 0") @@ -959,7 +968,7 @@ class BlePacketFactoryTest { weightPerCableKg = 30f, ) // Default production layout must preserve 0x48-0x4F as the profile tail. - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) // Concentric down ramp: C1507d(50, 450, 10.0f) assertEquals(50.toShort(), readShortLE(packet, 0x30), "conc.down.minMmS") @@ -991,7 +1000,7 @@ class BlePacketFactoryTest { weightPerCableKg = weight, progressionRegressionKg = 0f, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(0.0f, readFloatLE(packet, 0x50), "forces.min") assertEquals(40.0f, readFloatLE(packet, 0x54), "forces.max (10+weight)") @@ -1010,7 +1019,7 @@ class BlePacketFactoryTest { warmupReps = 3, weightPerCableKg = 40f, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(0x04.toByte(), packet[0x00], "command byte 0") @@ -1046,7 +1055,7 @@ class BlePacketFactoryTest { weightPerCableKg = 40f, ) // Default production layout must preserve 0x48-0x4F as the profile tail. - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) // Concentric down ramp: C1507d(250, 350, 7.0f) assertEquals(250.toShort(), readShortLE(packet, 0x30), "conc.down.minMmS") @@ -1078,7 +1087,7 @@ class BlePacketFactoryTest { weightPerCableKg = weight, progressionRegressionKg = 0f, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(0.0f, readFloatLE(packet, 0x50), "forces.min") assertEquals(50.0f, readFloatLE(packet, 0x54), "forces.max (10+weight)") @@ -1100,7 +1109,7 @@ class BlePacketFactoryTest { warmupReps = 3, weightPerCableKg = 60f, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(0x04.toByte(), packet[0x00], "command byte 0") @@ -1140,7 +1149,7 @@ class BlePacketFactoryTest { weightPerCableKg = 60f, ) // Default production layout must preserve 0x48-0x4F as the profile tail. - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) // Concentric down ramp: C1507d(50, 550, 50.0f) assertEquals(50.toShort(), readShortLE(packet, 0x30), "conc.down.minMmS") @@ -1172,7 +1181,7 @@ class BlePacketFactoryTest { weightPerCableKg = weight, progressionRegressionKg = 0f, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(0.0f, readFloatLE(packet, 0x50), "forces.min") assertEquals(70.0f, readFloatLE(packet, 0x54), "forces.max (10+weight)") @@ -1191,7 +1200,7 @@ class BlePacketFactoryTest { reps = 6, weightPerCableKg = 60f, ) - val packet = BlePacketFactory.createProgramParams( + val packet = programParams( params, variant = BlePacketFactory.ForceConfigVariant.OVERLAP, ) @@ -1216,7 +1225,7 @@ class BlePacketFactoryTest { weightPerCableKg = 40f, isJustLift = true, ) - val packet = BlePacketFactory.createProgramParams( + val packet = programParams( params, variant = BlePacketFactory.ForceConfigVariant.OVERLAP, ) @@ -1252,7 +1261,7 @@ class BlePacketFactoryTest { warmupReps = 3, weightPerCableKg = 50f, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(96, packet.size, "$name: packet size") assertEquals(0x04.toByte(), packet[0x00], "$name: command ID") @@ -1273,10 +1282,10 @@ class BlePacketFactoryTest { @Test fun `Eccentric bottom RepBound differs from other modes`() { - val eccentricPacket = BlePacketFactory.createProgramParams( + val eccentricPacket = programParams( WorkoutParameters(ProgramMode.EccentricOnly, reps = 10, weightPerCableKg = 50f), ) - val oldSchoolPacket = BlePacketFactory.createProgramParams( + val oldSchoolPacket = programParams( WorkoutParameters(ProgramMode.OldSchool, reps = 10, weightPerCableKg = 50f), ) @@ -1315,7 +1324,7 @@ class BlePacketFactoryTest { for (mode in modes) { val weight = 45f - val packet = BlePacketFactory.createProgramParams( + val packet = programParams( WorkoutParameters(mode, reps = 10, weightPerCableKg = weight), ) @@ -1336,7 +1345,7 @@ class BlePacketFactoryTest { weightPerCableKg = 70f, ) // Default production layout must preserve 0x48-0x4F as the profile tail. - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) // Concentric down ramp: C1507d(150, 250, 7.0f) assertEquals(150.toShort(), readShortLE(packet, 0x30), "conc.down.minMmS") @@ -1361,7 +1370,7 @@ class BlePacketFactoryTest { @Test fun `TUTBeast uses default RepConfig not Eccentric override`() { - val packet = BlePacketFactory.createProgramParams( + val packet = programParams( WorkoutParameters(ProgramMode.TUTBeast, reps = 6, weightPerCableKg = 70f), ) @@ -1382,7 +1391,7 @@ class BlePacketFactoryTest { progressionRegressionKg = 0f, isJustLift = true, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) // Reference: spec section 2.6 TUT fixture (96 bytes) @Suppress("ktlint:standard:max-line-length") @@ -1418,7 +1427,7 @@ class BlePacketFactoryTest { progressionRegressionKg = 0f, isJustLift = true, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) // Reference: spec section 2.6 Beast fixture (96 bytes) @Suppress("ktlint:standard:max-line-length") @@ -1451,7 +1460,7 @@ class BlePacketFactoryTest { weightPerCableKg = 40f, isJustLift = true, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) // Reps byte must be 0xFF for Just Lift (unlimited) assertEquals(0xFF.toByte(), packet[0x04], "Just Lift reps marker") @@ -1470,7 +1479,7 @@ class BlePacketFactoryTest { assertEquals(14.0f, readFloatLE(packet, 0x4C), "TUT ecc.up.ramp") // Force block still at correct offsets - assertEquals(50.0f, readFloatLE(packet, 0x54), "forceMax = weight + 10") + assertEquals(50.0f, readFloatLE(packet, 0x54), "forceMax = min(weight + 10, chassisMax)") assertEquals(40.0f, readFloatLE(packet, 0x58), "softMax = weight") } @@ -1482,7 +1491,7 @@ class BlePacketFactoryTest { weightPerCableKg = 40f, isJustLift = true, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) // Reps byte must be 0xFF for Just Lift assertEquals(0xFF.toByte(), packet[0x04], "Just Lift reps marker") @@ -1501,7 +1510,7 @@ class BlePacketFactoryTest { assertEquals(28.0f, readFloatLE(packet, 0x4C), "Beast ecc.up.ramp") // Force block still at correct offsets - assertEquals(50.0f, readFloatLE(packet, 0x54), "forceMax = weight + 10") + assertEquals(50.0f, readFloatLE(packet, 0x54), "forceMax = min(weight + 10, chassisMax)") assertEquals(40.0f, readFloatLE(packet, 0x58), "softMax = weight") } @@ -1519,8 +1528,8 @@ class BlePacketFactoryTest { weightPerCableKg = 40f, isJustLift = true, ) - val tutPacket = BlePacketFactory.createProgramParams(tutParams) - val beastPacket = BlePacketFactory.createProgramParams(beastParams) + val tutPacket = programParams(tutParams) + val beastPacket = programParams(beastParams) // Per spec section 2.4, only these byte ranges differ: // 0x30-0x33 (conc.down min/max), 0x38-0x3B (conc.up min/max), 0x4C-0x4F (ecc.up.ramp) @@ -1565,7 +1574,7 @@ class BlePacketFactoryTest { progressionRegressionKg = progressionKg, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(96, packet.size, "Packet must be 96 bytes") assertEquals(0x04.toByte(), packet[0], "Command byte must be ACTIVATION (0x04)") @@ -1576,13 +1585,13 @@ class BlePacketFactoryTest { assertEquals(0.0f, readFloatLE(packet, 0x4C), "OldSchool ecc.up.ramp at 0x4C") // Protocol force config (0x50-0x5F) - val forceMax = weightPerCableKg + 10.0f + val forceMax = ChassisLimits.forceMaxKg(weightPerCableKg, PhoenixModel.TrainerPlus) assertEquals(0.0f, readFloatLE(packet, 0x50), "forceMin at 0x50 must be 0") assertEquals( forceMax, readFloatLE(packet, 0x54), - "forceMax at 0x54 must be selected weight + 10", + "forceMax at 0x54 must be min(selected weight + 10, chassisMax)", ) assertEquals( weightPerCableKg, @@ -1615,14 +1624,14 @@ class BlePacketFactoryTest { progressionRegressionKg = 0.5f, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(96, packet.size) assertEquals((-260).toShort(), readShortLE(packet, 0x48), "OldSchool ecc.up.minMmS preserved") assertEquals(0.0f, readFloatLE(packet, 0x4C), "OldSchool ecc.up.ramp preserved") assertEquals(1.5f, readFloatLE(packet, 0x58), "targetWeight = selected weight") assertEquals(0.5f, readFloatLE(packet, 0x5C), "progression = progressionKg") - assertEquals(11.5f, readFloatLE(packet, 0x54), "forceMax = selected weight + 10") + assertEquals(11.5f, readFloatLE(packet, 0x54), "forceMax = min(selected weight + 10, chassisMax)") } @Test @@ -1637,7 +1646,7 @@ class BlePacketFactoryTest { isAMRAP = true, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(0xFF.toByte(), packet[0x04], "AMRAP reps marker") assertEquals((-260).toShort(), readShortLE(packet, 0x48), "AMRAP preserves OldSchool ecc.up.minMmS") @@ -1655,7 +1664,7 @@ class BlePacketFactoryTest { isJustLift = true, ) - val packet = BlePacketFactory.createProgramParams(params) + val packet = programParams(params) assertEquals(0xFF.toByte(), packet[0x04], "JustLift reps marker") assertEquals((-260).toShort(), readShortLE(packet, 0x48), "JustLift preserves OldSchool ecc.up.minMmS") @@ -1663,6 +1672,75 @@ class BlePacketFactoryTest { assertEquals(40.0f, readFloatLE(packet, 0x58), "JustLift targetWeight uses selected weight") } + @Test + fun `forceMax is capped at V-Form chassis max`() { + val params = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + weightPerCableKg = 100f, + ) + val packet = programParams(params, model = PhoenixModel.VFormTrainer) + assertEquals(100f, readFloatLE(packet, BleConstants.ActivationPacket.OFFSET_TARGET_WEIGHT)) + assertEquals( + 100f, + readFloatLE(packet, BleConstants.ActivationPacket.OFFSET_FORCE_MAX), + "forceMax must be min(weight+10, 100) on V-Form, not weight+10", + ) + assertEquals(0x04.toByte(), packet[0], "CONFIG opcode must stay 0x04") + } + + @Test + fun `forceMax is capped at Trainer+ chassis max`() { + val params = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + weightPerCableKg = 105f, + ) + val packet = programParams(params, model = PhoenixModel.TrainerPlus) + assertEquals(105f, readFloatLE(packet, BleConstants.ActivationPacket.OFFSET_TARGET_WEIGHT)) + assertEquals( + 110f, + readFloatLE(packet, BleConstants.ActivationPacket.OFFSET_FORCE_MAX), + "forceMax must be min(weight+10, 110) on Trainer+", + ) + } + + @Test + fun `unknown model fail-closes CONFIG at 100 kg and rejects 100_5`() { + val accepted = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + weightPerCableKg = 100f, + ) + val packet = programParams(accepted, model = PhoenixModel.Unknown) + assertEquals(100f, readFloatLE(packet, BleConstants.ActivationPacket.OFFSET_FORCE_MAX)) + + val rejected = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + weightPerCableKg = 100.5f, + ) + val result = runCatching { programParams(rejected, model = PhoenixModel.Unknown) } + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message.orEmpty().contains("100.5")) + } + + @Test + fun `createEchoControl does not encode kilograms on 0x4E`() { + val packet = BlePacketFactory.createEchoControl( + level = EchoLevel.HARD, + warmupReps = 3, + targetReps = 8, + eccentricPct = 100, + ) + assertEquals(32, packet.size) + assertEquals(0x4E.toByte(), packet[0], "Echo opcode must stay 0x4E") + // 0x4E is timing/profile only; selected kg lives on CONFIG 0x04, not here. + for (offset in 0x50 until 0x60) { + assertTrue(offset >= packet.size, "Echo packet must not contain CONFIG force offsets") + } + } + // ========== Issue #538: TUT/Beast Persistence Round-Trip Tests ========== @Test diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt index b92bbcc15..3ed7efcea 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt @@ -1,16 +1,23 @@ package com.devil.phoenixproject.util import com.devil.phoenixproject.domain.model.EchoLevel +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.WorkoutParameters import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue class WorkoutCommandValidatorTest { + private fun validateParams( + params: WorkoutParameters, + model: PhoenixModel = PhoenixModel.TrainerPlus, + ) = WorkoutCommandValidator.validateProgramParams(params, model) + @Test fun `program params accept normal finite bounded command`() { - val result = WorkoutCommandValidator.validateProgramParams( + val result = validateParams( WorkoutParameters( programMode = ProgramMode.OldSchool, reps = 8, @@ -25,7 +32,7 @@ class WorkoutCommandValidatorTest { @Test fun `normal workout commands allow fractional positive weight`() { assertTrue( - WorkoutCommandValidator.validateProgramParams( + validateParams( WorkoutParameters( programMode = ProgramMode.OldSchool, reps = 8, @@ -38,6 +45,7 @@ class WorkoutCommandValidatorTest { programMode = ProgramMode.OldSchool, weightPerCableKg = 0.5f, targetReps = 8, + model = PhoenixModel.TrainerPlus, ).isSuccess, ) } @@ -45,29 +53,105 @@ class WorkoutCommandValidatorTest { @Test fun `program params reject non-finite and out-of-range weights`() { assertFailureContains( - WorkoutCommandValidator.validateProgramParams( + validateParams( WorkoutParameters(ProgramMode.OldSchool, reps = 8, weightPerCableKg = Float.NaN), ), "finite", ) assertFailureContains( - WorkoutCommandValidator.validateProgramParams( + validateParams( WorkoutParameters(ProgramMode.OldSchool, reps = 8, weightPerCableKg = 111f), ), "weightPerCableKg", ) assertFailureContains( - WorkoutCommandValidator.validateProgramParams( + validateParams( WorkoutParameters(ProgramMode.OldSchool, reps = 8, weightPerCableKg = 0f), ), "greater than", ) } + @Test + fun `V-Form rejects 100_5 kg and accepts 100 kg`() { + assertTrue( + validateParams( + WorkoutParameters(ProgramMode.OldSchool, reps = 8, weightPerCableKg = 100f), + model = PhoenixModel.VFormTrainer, + ).isSuccess, + ) + assertFailureContains( + validateParams( + WorkoutParameters(ProgramMode.OldSchool, reps = 8, weightPerCableKg = 100.5f), + model = PhoenixModel.VFormTrainer, + ), + "100.5", + ) + assertFailureContains( + validateParams( + WorkoutParameters(ProgramMode.OldSchool, reps = 8, weightPerCableKg = 110f), + model = PhoenixModel.VFormTrainer, + ), + "weightPerCableKg", + ) + } + + @Test + fun `Trainer+ accepts 100_5 and 110 but rejects 111`() { + assertTrue( + validateParams( + WorkoutParameters(ProgramMode.OldSchool, reps = 8, weightPerCableKg = 100.5f), + model = PhoenixModel.TrainerPlus, + ).isSuccess, + ) + assertTrue( + validateParams( + WorkoutParameters(ProgramMode.OldSchool, reps = 8, weightPerCableKg = 110f), + model = PhoenixModel.TrainerPlus, + ).isSuccess, + ) + assertFailureContains( + validateParams( + WorkoutParameters(ProgramMode.OldSchool, reps = 8, weightPerCableKg = 111f), + model = PhoenixModel.TrainerPlus, + ), + "weightPerCableKg", + ) + } + + @Test + fun `unknown model fail-closes at 100 kg per cable`() { + assertTrue( + validateParams( + WorkoutParameters(ProgramMode.OldSchool, reps = 8, weightPerCableKg = 100f), + model = PhoenixModel.Unknown, + ).isSuccess, + ) + assertFailureContains( + validateParams( + WorkoutParameters(ProgramMode.OldSchool, reps = 8, weightPerCableKg = 100.5f), + model = PhoenixModel.Unknown, + ), + "100.5", + ) + assertEquals( + ChassisLimits.V_FORM_KG_PER_CABLE, + ChassisLimits.maxKgPerCable(PhoenixModel.Unknown), + ) + assertEquals( + ChassisLimits.V_FORM_KG_PER_CABLE, + ChassisLimits.maxKgPerCable(PhoenixModel.VFormTrainer), + ) + assertEquals( + ChassisLimits.TRAINER_PLUS_KG_PER_CABLE, + ChassisLimits.maxKgPerCable(PhoenixModel.TrainerPlus), + ) + } + @Test fun `just lift requires minimum nonzero weight`() { assertFailureContains( - WorkoutCommandValidator.validateProgramParams( + validateParams( WorkoutParameters( programMode = ProgramMode.OldSchool, reps = 1, @@ -81,7 +165,7 @@ class WorkoutCommandValidatorTest { @Test fun `amrap allows zero target reps but finite bounded weight still applies`() { - val result = WorkoutCommandValidator.validateProgramParams( + val result = validateParams( WorkoutParameters( programMode = ProgramMode.Pump, reps = 0, @@ -96,7 +180,7 @@ class WorkoutCommandValidatorTest { @Test fun `rep and warmup bytes must fit one byte`() { assertFailureContains( - WorkoutCommandValidator.validateProgramParams( + validateParams( WorkoutParameters( programMode = ProgramMode.Pump, reps = 253, @@ -124,7 +208,7 @@ class WorkoutCommandValidatorTest { // 0xFF (255) is the unlimited/Just Lift/AMRAP sentinel; a finite total of // 255 must be rejected so it cannot serialize to an unlimited workout. assertTrue( - WorkoutCommandValidator.validateProgramParams( + validateParams( WorkoutParameters( programMode = ProgramMode.Pump, reps = 251, @@ -135,7 +219,7 @@ class WorkoutCommandValidatorTest { "reps+warmup == 254 should be accepted", ) assertFailureContains( - WorkoutCommandValidator.validateProgramParams( + validateParams( WorkoutParameters( programMode = ProgramMode.Pump, reps = 252, From ccdc93489b86db6a5d5164464c5d70968c1762d7 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 23 Aug 2026 14:34:53 -0400 Subject: [PATCH 2/5] fix: chassis-aware rack/recommend/RFM clamps and progression headroom Thread PhoenixModel into rack calculate, set-ready weight, and weight-increase recommendations. Clamp finite-rep CONFIG progression to remaining chassis headroom. Wire HardwareDetection.getCapabilities to per-cable ChassisLimits. Expand host goldens and fail-close test helpers. --- ...ActiveSessionEngineChassisLimitHostTest.kt | 132 ++++++++++++------ .../domain/model/WeightRecommendation.kt | 1 + .../usecase/ApplyEquipmentRackLoadUseCase.kt | 10 +- .../RecommendWeightAdjustmentUseCase.kt | 4 +- .../manager/ActiveSessionEngine.kt | 21 +++ .../manager/DefaultWorkoutSessionManager.kt | 2 + .../manager/RoutineFlowManager.kt | 11 +- .../devil/phoenixproject/util/BleConstants.kt | 12 +- .../phoenixproject/util/BlePacketFactory.kt | 11 +- .../phoenixproject/util/ChassisLimits.kt | 26 +++- .../phoenixproject/util/HardwareDetection.kt | 44 +++--- .../ApplyEquipmentRackLoadUseCaseTest.kt | 37 ++++- .../usecase/DropSetCandidateResolverTest.kt | 24 +++- .../usecase/DropSetEligibilityPolicyTest.kt | 2 +- .../RecommendWeightAdjustmentUseCaseTest.kt | 19 ++- .../components/BulkWeightAdjustTest.kt | 2 +- .../util/BlePacketFactoryTest.kt | 98 ++++++++++++- .../util/WorkoutCommandValidatorTest.kt | 5 +- 18 files changed, 361 insertions(+), 100 deletions(-) diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngineChassisLimitHostTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngineChassisLimitHostTest.kt index a2d1429c8..39085eeb3 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngineChassisLimitHostTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngineChassisLimitHostTest.kt @@ -9,6 +9,7 @@ import com.devil.phoenixproject.util.HardwareDetection import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -22,71 +23,114 @@ class ActiveSessionEngineChassisLimitHostTest { @Test fun `V-Form send site rejects 100_5 kg and does not write CONFIG`() = runTest { + assertRejectedConfig( + deviceName = "Vee_Test", + expectedModel = PhoenixModel.VFormTrainer, + weightPerCableKg = 100.5f, + ) + } + + @Test + fun `V-Form send site accepts 100 kg CONFIG with forceMax 100`() = runTest { + val config = assertAcceptedConfig( + deviceName = "Vee_Test", + expectedModel = PhoenixModel.VFormTrainer, + weightPerCableKg = 100f, + ) + assertEquals(100f, readFloatLE(config, BleConstants.ActivationPacket.OFFSET_TARGET_WEIGHT)) + assertEquals(100f, readFloatLE(config, BleConstants.ActivationPacket.OFFSET_FORCE_MAX)) + } + + @Test + fun `Trainer+ send site accepts 100_5 kg CONFIG with forceMax 110`() = runTest { + val config = assertAcceptedConfig( + deviceName = "VIT_Test", + expectedModel = PhoenixModel.TrainerPlus, + weightPerCableKg = 100.5f, + ) + assertEquals(100.5f, readFloatLE(config, BleConstants.ActivationPacket.OFFSET_TARGET_WEIGHT)) + assertEquals(110f, readFloatLE(config, BleConstants.ActivationPacket.OFFSET_FORCE_MAX)) + } + + @Test + fun `unknown advertised name send site rejects 100_5 kg`() = runTest { + assertRejectedConfig( + deviceName = "Phoenix_Test", + expectedModel = PhoenixModel.Unknown, + weightPerCableKg = 100.5f, + ) + } + + private fun TestScope.assertRejectedConfig( + deviceName: String, + expectedModel: PhoenixModel, + weightPerCableKg: Float, + ) { val harness = DWSMTestHarness(this) val bleErrors = mutableListOf() backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { harness.coordinator.bleErrorEvents.collect(bleErrors::add) } try { - harness.fakeBleRepo.simulateConnect("Vee_Test") - assertEquals( - PhoenixModel.VFormTrainer, - HardwareDetection.detectModel("Vee_Test"), - ) - - harness.dwsm.updateWorkoutParameters( - WorkoutParameters( - programMode = ProgramMode.OldSchool, - reps = 8, - warmupReps = 0, - weightPerCableKg = 100.5f, - isJustLift = true, - ), - ) - harness.dwsm.startWorkout(skipCountdown = true, isJustLiftMode = true) + harness.fakeBleRepo.simulateConnect(deviceName) + assertEquals(expectedModel, HardwareDetection.detectModel(deviceName)) + startJustLift(harness, weightPerCableKg) advanceUntilIdle() - - val configWrites = harness.fakeBleRepo.commandsReceived.filter { - it.isNotEmpty() && it[0] == BleConstants.Commands.ACTIVATION_COMMAND - } assertTrue( - configWrites.isEmpty(), - "V-Form must not send CONFIG at 100.5 kg/cable; got ${configWrites.size} writes", + configWrites(harness).isEmpty(), + "$deviceName must not send CONFIG at ${weightPerCableKg}kg; got ${configWrites(harness).size}", ) assertTrue( - bleErrors.any { it.contains("Invalid BLE workout command") && it.contains("100.5") }, - "Expected send-site rejection of 100.5 kg on V-Form, got $bleErrors", + bleErrors.any { it.contains("Invalid BLE workout command") && it.contains(weightPerCableKg.toString()) }, + "Expected send-site rejection of $weightPerCableKg kg on $deviceName, got $bleErrors", ) } finally { harness.cleanup() } } - @Test - fun `Trainer+ send site accepts 100_5 kg CONFIG`() = runTest { + private fun TestScope.assertAcceptedConfig( + deviceName: String, + expectedModel: PhoenixModel, + weightPerCableKg: Float, + ): ByteArray { val harness = DWSMTestHarness(this) try { - harness.fakeBleRepo.simulateConnect("VIT_Test") - assertEquals(PhoenixModel.TrainerPlus, HardwareDetection.detectModel("VIT_Test")) - - harness.dwsm.updateWorkoutParameters( - WorkoutParameters( - programMode = ProgramMode.OldSchool, - reps = 8, - warmupReps = 0, - weightPerCableKg = 100.5f, - isJustLift = true, - ), - ) - harness.dwsm.startWorkout(skipCountdown = true, isJustLiftMode = true) + harness.fakeBleRepo.simulateConnect(deviceName) + assertEquals(expectedModel, HardwareDetection.detectModel(deviceName)) + startJustLift(harness, weightPerCableKg) advanceUntilIdle() - - val configWrites = harness.fakeBleRepo.commandsReceived.filter { - it.isNotEmpty() && it[0] == BleConstants.Commands.ACTIVATION_COMMAND - } - assertTrue(configWrites.isNotEmpty(), "Trainer+ must send CONFIG at 100.5 kg/cable") + val writes = configWrites(harness) + assertTrue(writes.isNotEmpty(), "$deviceName must send CONFIG at ${weightPerCableKg}kg") + return writes.first() } finally { harness.cleanup() } } + + private fun startJustLift(harness: DWSMTestHarness, weightPerCableKg: Float) { + harness.dwsm.updateWorkoutParameters( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + warmupReps = 0, + weightPerCableKg = weightPerCableKg, + isJustLift = true, + ), + ) + harness.dwsm.startWorkout(skipCountdown = true, isJustLiftMode = true) + } + + private fun configWrites(harness: DWSMTestHarness): List = + harness.fakeBleRepo.commandsReceived.filter { + it.isNotEmpty() && it[0] == BleConstants.Commands.ACTIVATION_COMMAND + } + + private fun readFloatLE(buffer: ByteArray, offset: Int): Float { + val bits = (buffer[offset].toInt() and 0xFF) or + ((buffer[offset + 1].toInt() and 0xFF) shl 8) or + ((buffer[offset + 2].toInt() and 0xFF) shl 16) or + ((buffer[offset + 3].toInt() and 0xFF) shl 24) + return Float.fromBits(bits) + } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/WeightRecommendation.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/WeightRecommendation.kt index c7ec0bede..561dda221 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/WeightRecommendation.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/WeightRecommendation.kt @@ -36,6 +36,7 @@ data class WeightAdjustmentInput( val biomechanicsSummary: BiomechanicsSetSummary?, val isBodyweight: Boolean, val hasNextSetTarget: Boolean, + val hardwareModel: PhoenixModel, ) /** diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ApplyEquipmentRackLoadUseCase.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ApplyEquipmentRackLoadUseCase.kt index 0cf83de82..58750e9cc 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ApplyEquipmentRackLoadUseCase.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ApplyEquipmentRackLoadUseCase.kt @@ -1,9 +1,11 @@ package com.devil.phoenixproject.domain.usecase +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.RackItem import com.devil.phoenixproject.domain.model.RackItemBehavior import com.devil.phoenixproject.domain.model.RackLoadContribution import com.devil.phoenixproject.domain.model.RackLoadAdjustment +import com.devil.phoenixproject.util.ChassisLimits import com.devil.phoenixproject.util.Constants class ApplyEquipmentRackLoadUseCase { @@ -12,6 +14,7 @@ class ApplyEquipmentRackLoadUseCase { physicalCableCount: Int, selectedItems: List, isEchoMode: Boolean, + hardwareModel: PhoenixModel, validatorMinimumPerCableKg: Float = Constants.MIN_WEIGHT_KG, behaviorOverrides: Map = emptyMap(), ): RackLoadAdjustment { @@ -25,16 +28,17 @@ class ApplyEquipmentRackLoadUseCase { externalAddedLoadKg - counterweightKg ).coerceAtLeast(0f) + val chassisMax = ChassisLimits.maxKgPerCable(hardwareModel) val adjustedMachineWeightPerCableKg = if (isEchoMode) { programmedWeightPerCableKg } else { (programmedWeightPerCableKg - (counterweightKg / cableCount)) .coerceIn( // F373: clamp the lower bound below the ceiling. A caller-supplied - // validatorMinimumPerCableKg above MAX_WEIGHT_PER_CABLE_KG would make + // validatorMinimumPerCableKg above chassis max would make // coerceIn(min, max) have min > max and throw. - validatorMinimumPerCableKg.coerceIn(Constants.MIN_WEIGHT_KG, Constants.MAX_WEIGHT_PER_CABLE_KG), - Constants.MAX_WEIGHT_PER_CABLE_KG, + validatorMinimumPerCableKg.coerceIn(Constants.MIN_WEIGHT_KG, chassisMax), + chassisMax, ) } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/RecommendWeightAdjustmentUseCase.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/RecommendWeightAdjustmentUseCase.kt index 49c3f3d95..4139c106e 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/RecommendWeightAdjustmentUseCase.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/RecommendWeightAdjustmentUseCase.kt @@ -6,6 +6,7 @@ import com.devil.phoenixproject.domain.model.SetQualitySummary import com.devil.phoenixproject.domain.model.WeightAdjustmentDirection import com.devil.phoenixproject.domain.model.WeightAdjustmentInput import com.devil.phoenixproject.domain.model.WeightAdjustmentRecommendation +import com.devil.phoenixproject.util.ChassisLimits import com.devil.phoenixproject.util.Constants import kotlin.math.ceil import kotlin.math.floor @@ -103,7 +104,8 @@ class RecommendWeightAdjustmentUseCase { quality: SetQualitySummary, ): WeightAdjustmentRecommendation? { val recommended = nextHigherIncrement(input.currentWeightKgPerCable, input.weightIncrementKg) - if (recommended > Constants.MAX_WEIGHT_PER_CABLE_KG || recommended <= input.currentWeightKgPerCable) return null + val chassisMax = ChassisLimits.maxKgPerCable(input.hardwareModel) + if (recommended > chassisMax || recommended <= input.currentWeightKgPerCable) return null val confidence = if (quality.repScores.size >= HIGH_CONFIDENCE_REP_COUNT) { RecommendationConfidence.HIGH 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 b935d2483..f752393a6 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 @@ -3942,6 +3942,7 @@ class ActiveSessionEngine( physicalCableCount = physicalCableCount, selectedItems = selectedItems, isEchoMode = request.params.isEchoMode, + hardwareModel = chassisModel(), validatorMinimumPerCableKg = validatorSafeMinimum(request.params), behaviorOverrides = behaviorOverrides, ) @@ -5247,6 +5248,7 @@ class ActiveSessionEngine( physicalCableCount = physicalCableCount, selectedItems = selectedItems, isEchoMode = params.isEchoMode, + hardwareModel = chassisModel(), validatorMinimumPerCableKg = validatorSafeMinimum(params), behaviorOverrides = behaviorOverrides, ) @@ -5272,6 +5274,7 @@ class ActiveSessionEngine( physicalCableCount = physicalCableCount, selectedItems = selectedItems, isEchoMode = params.isEchoMode, + hardwareModel = chassisModel(), validatorMinimumPerCableKg = validatorSafeMinimum(params), behaviorOverrides = behaviorOverrides, ) @@ -7192,6 +7195,7 @@ class ActiveSessionEngine( physicalCableCount = physicalCableCount, selectedItems = resolvedItems, isEchoMode = currentParams.isEchoMode, + hardwareModel = chassisModel(), validatorMinimumPerCableKg = validatorSafeMinimum(currentParams), behaviorOverrides = coordinator._activeRackBehaviorOverrides.value, ) @@ -7249,6 +7253,7 @@ class ActiveSessionEngine( physicalCableCount = exercise.exercise.preferredCableCount ?: 1, selectedItems = resolvedItems, isEchoMode = currentParams.isEchoMode, + hardwareModel = chassisModel(), validatorMinimumPerCableKg = validatorSafeMinimum(currentParams), behaviorOverrides = overrides, ) @@ -8248,6 +8253,21 @@ class ActiveSessionEngine( ?: resolveSelectedExercise(effectiveParams)?.preferredCableCount ?: 1 val rackSnapshotItems = coordinator._currentRackLoadAdjustment.value.selectedItems + val preRackValidation = if (warmupOverrideParams.isEchoMode) { + Result.success(Unit) + } else { + WorkoutCommandValidator.validateProgramParams(warmupOverrideParams, chassisModel()) + } + preRackValidation.onFailure { error -> + Logger.e(error) { "Invalid BLE workout command parameters: ${error.message}" } + coordinator._bleErrorEvents.tryEmit("Invalid BLE workout command: ${error.message}") + if (retryRequest != null) { + failRetryStartAndRecover(retryRequest, lease, priorWorkoutState) + } else { + failStart(lease, priorWorkoutState) + } + return@launch + } val bleParams = run { val base = if (isTimedCableExercise) { Logger.d { "Duration cable: overriding isAMRAP=true for BLE command (prevents machine rep limit)" } @@ -11544,6 +11564,7 @@ class ActiveSessionEngine( hasNextSetTarget = isSameExercise && !targetExerciseIsBodyweight && completedSetHasTarget, + hardwareModel = chassisModel(), ) coordinator._weightAdjustmentRecommendation.value = recommendWeightAdjustmentUseCase(input) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt index 13e431561..befbb685b 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt @@ -51,6 +51,7 @@ import com.devil.phoenixproject.domain.usecase.RegenerateFiveThreeOneRoutinesUse import com.devil.phoenixproject.domain.usecase.RepCounterFromMachine import com.devil.phoenixproject.domain.usecase.ResolveRoutineWeightsUseCase import com.devil.phoenixproject.getPlatform +import com.devil.phoenixproject.util.ChassisLimits import com.devil.phoenixproject.util.DataBackupManager import com.devil.phoenixproject.util.KmpUtils import kotlinx.coroutines.CancellationException @@ -344,6 +345,7 @@ class DefaultWorkoutSessionManager( override fun resolveOccurrenceSetWeight(exercise: RoutineExercise, setIndex: Int): Float = activeSessionEngine.resolveOccurrenceSetWeight(exercise, setIndex) override fun beginRoutineCompletedRuntimeCleanup() = activeSessionEngine.beginRoutineCompletedRuntimeCleanup() override fun beginRoutineAbandonmentRuntimeCleanup() = activeSessionEngine.beginRoutineAbandonmentRuntimeCleanup() + override fun chassisModel() = ChassisLimits.modelOf(bleRepository.connectionState.value) } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt index 225eadf9d..a1483f1c5 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt @@ -11,6 +11,7 @@ import com.devil.phoenixproject.domain.model.ActiveRackSelection import com.devil.phoenixproject.domain.model.AppliedRoutineModifier import com.devil.phoenixproject.domain.model.EccentricLoad import com.devil.phoenixproject.domain.model.EchoLevel +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.RackItem import com.devil.phoenixproject.domain.model.RackItemBehavior @@ -36,6 +37,7 @@ import com.devil.phoenixproject.domain.usecase.ApplyRoutineModifierUseCase import com.devil.phoenixproject.domain.usecase.ResolveRoutineWeightsUseCase import com.devil.phoenixproject.domain.usecase.RoutineSetWeightRequest import com.devil.phoenixproject.domain.usecase.RoutineSetWeightResolver +import com.devil.phoenixproject.util.ChassisLimits import com.devil.phoenixproject.util.Constants import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -163,6 +165,9 @@ class RoutineFlowManager( /** Capture persisted-runtime cleanup before intentionally abandoning/replacing a routine. */ fun beginRoutineAbandonmentRuntimeCleanup() + + /** Connected chassis, or Unknown fail-closed 100 kg/cable. */ + fun chassisModel(): PhoenixModel = PhoenixModel.Unknown } /** @@ -1024,6 +1029,7 @@ class RoutineFlowManager( physicalCableCount = physicalCableCount, selectedItems = resolvedItems, isEchoMode = exercise.programMode is ProgramMode.Echo, + hardwareModel = lifecycleDelegate.chassisModel(), validatorMinimumPerCableKg = Constants.DEFAULT_WEIGHT_INCREMENT_KG, behaviorOverrides = exercise.rackBehaviorOverrides, ) @@ -1362,7 +1368,10 @@ class RoutineFlowManager( supersedeConfigurationInputIntent() val state = coordinator._routineFlowState.value if (state is RoutineFlowState.SetReady) { - val clampedWeight = weight.coerceIn(Constants.MIN_WEIGHT_KG, Constants.MAX_WEIGHT_PER_CABLE_KG) + val clampedWeight = weight.coerceIn( + Constants.MIN_WEIGHT_KG, + ChassisLimits.maxKgPerCable(lifecycleDelegate.chassisModel()), + ) lifecycleDelegate.mutateConfigurationInputs { coordinator._routineFlowState.value = state.copy(adjustedWeight = clampedWeight) coordinator._workoutParameters.value = coordinator._workoutParameters.value.copy(weightPerCableKg = clampedWeight) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt index cc2a38e5e..ab68788fb 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt @@ -67,9 +67,9 @@ object BleConstants { * - 0x40-0x4F: eccentric activation phase * - 0x48-0x4F: eccentric-up ramp inside the eccentric activation phase * - 0x50-0x53: forceMin (0.0f) - * - 0x54-0x57: forceMax (min(adjustedWeight + 10.0f, chassisMax) — force ceiling) - * - 0x58-0x5B: target weight (adjustedWeight — actual operating weight) - * - 0x5C-0x5F: progression (progressionRegressionKg) + * - 0x54-0x57: forceMax (min(selectedWeightPerCableKg + 10.0f, chassisMax) — force ceiling) + * - 0x58-0x5B: target weight (selectedWeightPerCableKg — actual operating weight) + * - 0x5C-0x5F: progression (finite-rep headroom clamp of progressionRegressionKg) * * Activation packets keep 0x48-0x4F as the mode profile's eccentric-up * ramp bytes. The active force fields live in the trailing block at 0x50-0x5F. @@ -93,9 +93,9 @@ object BleConstants { // Force config block const val OFFSET_FORCE_MIN = 0x50 // 0.0f in activation packets - const val OFFSET_FORCE_MAX = 0x54 // min(adjustedWeight + 10.0f, chassisMax) - const val OFFSET_TARGET_WEIGHT = 0x58 // adjustedWeight (actual operating weight) - const val OFFSET_PROGRESSION = 0x5C // progressionRegressionKg + const val OFFSET_FORCE_MAX = 0x54 // min(selectedWeightPerCableKg + 10.0f, chassisMax) + const val OFFSET_TARGET_WEIGHT = 0x58 // selectedWeightPerCableKg + const val OFFSET_PROGRESSION = 0x5C // finite-rep chassis-headroom clamp } // Legacy aliases for backward compatibility diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt index 4f8bc8fa9..cb26a0406 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt @@ -229,6 +229,13 @@ object BlePacketFactory { // capped at the chassis ceiling so V-Form never sees 110 kg. val targetWeightPerCable = params.weightPerCableKg val effectiveKg = ChassisLimits.forceMaxKg(targetWeightPerCable, model) + val encodedProgression = ChassisLimits.finiteRepProgressionKg( + requestedKg = params.progressionRegressionKg, + weightPerCableKg = targetWeightPerCable, + reps = params.reps, + model = model, + unlimitedReps = params.isJustLift || params.isAMRAP, + ) // Normal force modes keep softMax tied to the selected force // per cable. Unlimited-rep behavior is controlled by the reps field @@ -251,7 +258,7 @@ object BlePacketFactory { putFloatLE( frame, BleConstants.ActivationPacket.OFFSET_INCREMENT, - params.progressionRegressionKg, + encodedProgression, ) } @@ -266,7 +273,7 @@ object BlePacketFactory { putFloatLE( frame, BleConstants.ActivationPacket.OFFSET_PROGRESSION, - params.progressionRegressionKg, + encodedProgression, ) // Diagnostic logging diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/ChassisLimits.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/ChassisLimits.kt index 242dbc26f..0997a506a 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/ChassisLimits.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/ChassisLimits.kt @@ -4,13 +4,15 @@ import com.devil.phoenixproject.domain.model.ConnectionState import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.WeightUnit import kotlin.math.floor +import kotlin.math.max import kotlin.math.min /** * Per-cable chassis limits. Unknown hardware fail-closes to V-Form (100 kg/cable). * - * CONFIG forceMax is [forceMaxKg] = min(selected + 10, chassisMax). Echo 0x4E does - * not carry kilograms — only the 0x04 CONFIG floats are clamped. + * CONFIG forceMax is [forceMaxKg] = min(selected + 10, chassisMax). Finite-rep + * progression is [finiteRepProgressionKg] so implied peak cannot exceed chassis. + * Echo 0x4E does not carry kilograms — only the 0x04 CONFIG floats are clamped. */ object ChassisLimits { const val V_FORM_KG_PER_CABLE = 100f @@ -31,6 +33,26 @@ object ChassisLimits { fun forceMaxKg(weightPerCableKg: Float, model: PhoenixModel): Float = min(weightPerCableKg + FORCE_MAX_HEADROOM_KG, maxKgPerCable(model)) + /** + * Finite-rep CONFIG progression so implied peak + * `weight + progression * (reps-1)` cannot exceed chassis max. + * AMRAP / Just Lift leave the requested increment unchanged ([forceMaxKg] is the cap). + * Negative regression is unchanged. + */ + fun finiteRepProgressionKg( + requestedKg: Float, + weightPerCableKg: Float, + reps: Int, + model: PhoenixModel, + unlimitedReps: Boolean, + ): Float { + if (!requestedKg.isFinite()) return 0f + if (unlimitedReps || requestedKg <= 0f) return requestedKg + val steps = max(reps - 1, 1) + val headroom = ((maxKgPerCable(model) - weightPerCableKg) / steps).coerceAtLeast(0f) + return min(requestedKg, headroom) + } + fun maxDisplay(model: PhoenixModel, unit: WeightUnit): Float { val kg = maxKgPerCable(model) return if (unit == WeightUnit.LB) { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/HardwareDetection.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/HardwareDetection.kt index 7360adbe4..eed5bfd4e 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/HardwareDetection.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/HardwareDetection.kt @@ -3,17 +3,14 @@ package com.devil.phoenixproject.util import com.devil.phoenixproject.domain.model.PhoenixModel /** - * Trainer Hardware Detection + * Trainer hardware detection from the advertised BLE name. * - * Previously attempted to identify hardware models (V-Form, Trainer+) from device name prefixes, - * but this approach was flawed - device name patterns don't reliably indicate hardware capabilities. + * This prefix map **is** the chassis-limit signal used by [ChassisLimits]: + * - `Vee_` → V-Form (100 kg/cable) + * - `VIT` → Trainer+ (110 kg/cable) + * - anything else, including empty/disconnected → [PhoenixModel.Unknown] fail-closed 100 kg/cable * - * Current approach: Report only what we can actually detect (device name) and avoid making - * assumptions about capabilities. True capability detection would require reading firmware - * version from the device, which is not currently implemented. - * - * The VERSION BLE characteristic (UUID: 74e994ac-0e80-4c02-9cd0-76cb31d3959b) contains - * hardware/firmware info but the parsing format is undocumented. + * Firmware VERSION is still unused. Do not "fix" unknown names to Trainer+ 110. */ object HardwareDetection { @@ -29,36 +26,35 @@ object HardwareDetection { } /** - * Get device display info without making capability assumptions + * Get device display info without making extra capability assumptions */ fun getDeviceDisplayInfo(deviceName: String): String = "Trainer ($deviceName)" /** - * Get hardware capabilities - currently returns defaults since we can't - * reliably detect hardware model from device name alone. - * - * All capabilities are assumed to be available until we can implement - * proper firmware version detection. + * Per-cable chassis capabilities derived from [detectModel]. + * [HardwareCapabilities.maxResistanceKg] is kg **per cable**, not total. */ - fun getCapabilities(deviceName: String): HardwareCapabilities = HardwareCapabilities.DEFAULT + fun getCapabilities(deviceName: String): HardwareCapabilities { + val model = detectModel(deviceName) + return HardwareCapabilities( + supportsEccentricMode = true, + supportsEchoMode = true, + maxResistanceKg = ChassisLimits.maxKgPerCable(model), + ) + } } /** - * Hardware capabilities for supported trainers + * Hardware capabilities for supported trainers. * - * Note: Without firmware version detection, we assume all features are available. - * This is safer than incorrectly disabling features based on flawed model detection. + * [maxResistanceKg] is the per-cable chassis ceiling from [ChassisLimits]. */ data class HardwareCapabilities(val supportsEccentricMode: Boolean, val supportsEchoMode: Boolean, val maxResistanceKg: Float) { companion object { - /** - * Default capabilities - assume all features available - * Conservative max resistance of 200kg (lowest known model) - */ val DEFAULT = HardwareCapabilities( supportsEccentricMode = true, supportsEchoMode = true, - maxResistanceKg = 200f, + maxResistanceKg = ChassisLimits.UNKNOWN_FAIL_CLOSED_KG_PER_CABLE, ) } } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ApplyEquipmentRackLoadUseCaseTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ApplyEquipmentRackLoadUseCaseTest.kt index f7cb6dc92..dd377ebfa 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ApplyEquipmentRackLoadUseCaseTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ApplyEquipmentRackLoadUseCaseTest.kt @@ -1,9 +1,10 @@ package com.devil.phoenixproject.domain.usecase +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.RackItem import com.devil.phoenixproject.domain.model.RackItemBehavior import com.devil.phoenixproject.domain.model.RackItemCategory -import com.devil.phoenixproject.util.Constants +import com.devil.phoenixproject.util.ChassisLimits import kotlin.test.Test import kotlin.test.assertEquals @@ -17,6 +18,7 @@ class ApplyEquipmentRackLoadUseCaseTest { physicalCableCount = 1, selectedItems = listOf(rackItem("vest", 10f, RackItemBehavior.ADDED_RESISTANCE)), isEchoMode = false, + hardwareModel = PhoenixModel.Unknown, ) assertEquals(10f, result.externalAddedLoadKg) @@ -37,6 +39,7 @@ class ApplyEquipmentRackLoadUseCaseTest { physicalCableCount = 1, selectedItems = listOf(rackItem("plate carrier", 30f, RackItemBehavior.DISPLAY_ONLY)), isEchoMode = false, + hardwareModel = PhoenixModel.Unknown, ) assertEquals(0f, result.externalAddedLoadKg) @@ -53,6 +56,7 @@ class ApplyEquipmentRackLoadUseCaseTest { physicalCableCount = 2, selectedItems = listOf(rackItem("assist", 12f, RackItemBehavior.COUNTERWEIGHT)), isEchoMode = false, + hardwareModel = PhoenixModel.Unknown, validatorMinimumPerCableKg = 1f, ) @@ -72,6 +76,7 @@ class ApplyEquipmentRackLoadUseCaseTest { physicalCableCount = 2, selectedItems = listOf(rackItem("assist", 10f, RackItemBehavior.COUNTERWEIGHT)), isEchoMode = false, + hardwareModel = PhoenixModel.Unknown, validatorMinimumPerCableKg = 1f, ) @@ -87,17 +92,35 @@ class ApplyEquipmentRackLoadUseCaseTest { physicalCableCount = 1, selectedItems = listOf(rackItem("assist", 20f, RackItemBehavior.COUNTERWEIGHT)), isEchoMode = false, + hardwareModel = PhoenixModel.Unknown, validatorMinimumPerCableKg = 1f, ) - val maximum = useCase.calculate( + val unknownMax = useCase.calculate( programmedWeightPerCableKg = 120f, physicalCableCount = 1, selectedItems = emptyList(), isEchoMode = false, + hardwareModel = PhoenixModel.Unknown, + ) + val vFormMax = useCase.calculate( + programmedWeightPerCableKg = 120f, + physicalCableCount = 1, + selectedItems = emptyList(), + isEchoMode = false, + hardwareModel = PhoenixModel.VFormTrainer, + ) + val trainerPlusMax = useCase.calculate( + programmedWeightPerCableKg = 120f, + physicalCableCount = 1, + selectedItems = emptyList(), + isEchoMode = false, + hardwareModel = PhoenixModel.TrainerPlus, ) assertEquals(1f, minimum.adjustedMachineWeightPerCableKg) - assertEquals(Constants.MAX_WEIGHT_PER_CABLE_KG, maximum.adjustedMachineWeightPerCableKg) + assertEquals(ChassisLimits.UNKNOWN_FAIL_CLOSED_KG_PER_CABLE, unknownMax.adjustedMachineWeightPerCableKg) + assertEquals(ChassisLimits.V_FORM_KG_PER_CABLE, vFormMax.adjustedMachineWeightPerCableKg) + assertEquals(ChassisLimits.TRAINER_PLUS_KG_PER_CABLE, trainerPlusMax.adjustedMachineWeightPerCableKg) } @Test @@ -111,6 +134,7 @@ class ApplyEquipmentRackLoadUseCaseTest { physicalCableCount = 1, selectedItems = listOf(vest, chain, vest, assist), isEchoMode = false, + hardwareModel = PhoenixModel.Unknown, ) assertEquals(15f, result.externalAddedLoadKg) @@ -135,6 +159,7 @@ class ApplyEquipmentRackLoadUseCaseTest { physicalCableCount = 1, selectedItems = listOf(rackItem("assist", 10f, RackItemBehavior.COUNTERWEIGHT)), isEchoMode = true, + hardwareModel = PhoenixModel.Unknown, ) assertEquals(30f, result.displayLoadKg) @@ -166,6 +191,7 @@ class ApplyEquipmentRackLoadUseCaseTest { physicalCableCount = 2, selectedItems = listOf(vest), isEchoMode = false, + hardwareModel = PhoenixModel.Unknown, ) assertEquals(5f, result.externalAddedLoadKg) assertEquals(0f, result.counterweightKg) @@ -180,6 +206,7 @@ class ApplyEquipmentRackLoadUseCaseTest { physicalCableCount = 2, selectedItems = listOf(vest), isEchoMode = false, + hardwareModel = PhoenixModel.Unknown, behaviorOverrides = overrides, ) assertEquals(0f, result.externalAddedLoadKg) @@ -198,6 +225,7 @@ class ApplyEquipmentRackLoadUseCaseTest { physicalCableCount = 2, selectedItems = listOf(vest), isEchoMode = false, + hardwareModel = PhoenixModel.Unknown, behaviorOverrides = overrides, ) assertEquals(0f, result.externalAddedLoadKg) @@ -218,6 +246,7 @@ class ApplyEquipmentRackLoadUseCaseTest { physicalCableCount = 2, selectedItems = listOf(vest, ankle), isEchoMode = false, + hardwareModel = PhoenixModel.Unknown, behaviorOverrides = overrides, ) assertEquals(2f, result.externalAddedLoadKg) // ankle only @@ -237,6 +266,7 @@ class ApplyEquipmentRackLoadUseCaseTest { physicalCableCount = 2, selectedItems = listOf(vest), isEchoMode = false, + hardwareModel = PhoenixModel.Unknown, behaviorOverrides = emptyMap(), ) val without = useCase.calculate( @@ -244,6 +274,7 @@ class ApplyEquipmentRackLoadUseCaseTest { physicalCableCount = 2, selectedItems = listOf(vest), isEchoMode = false, + hardwareModel = PhoenixModel.Unknown, ) assertEquals(without.externalAddedLoadKg, withOverrides.externalAddedLoadKg) assertEquals(without.counterweightKg, withOverrides.counterweightKg) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/DropSetCandidateResolverTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/DropSetCandidateResolverTest.kt index 18ffc985b..c3d86d8e7 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/DropSetCandidateResolverTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/DropSetCandidateResolverTest.kt @@ -3,6 +3,7 @@ package com.devil.phoenixproject.domain.usecase import com.devil.phoenixproject.domain.model.DropPercentage import com.devil.phoenixproject.domain.model.DropSetCandidateInvalidReason import com.devil.phoenixproject.domain.model.DropSetCandidateResolution +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.WorkoutParameters import kotlin.test.Test @@ -157,9 +158,27 @@ class DropSetCandidateResolverTest { assertEquals( 110f, assertIs( - resolve(DropPercentage.TEN, start = 122.25f, base = 122.25f, floor = 1f), + resolve( + DropPercentage.TEN, + start = 122.25f, + base = 122.25f, + floor = 1f, + hardwareModel = PhoenixModel.TrainerPlus, + ), ).candidate.resolvedWeightPerCableKg, ) + assertEquals( + DropSetCandidateInvalidReason.INVALID_COMMAND, + assertIs( + resolve( + DropPercentage.TEN, + start = 122.25f, + base = 122.25f, + floor = 1f, + hardwareModel = PhoenixModel.VFormTrainer, + ), + ).reason, + ) assertEquals( DropSetCandidateInvalidReason.INVALID_COMMAND, assertIs( @@ -222,6 +241,7 @@ class DropSetCandidateResolverTest { base: Float = 50f, floor: Float = 1f, template: WorkoutParameters = commandTemplate(), + hardwareModel: PhoenixModel = PhoenixModel.Unknown, ): DropSetCandidateResolution = resolver.resolve( DropSetCandidateRequest( percentage = percentage, @@ -229,7 +249,7 @@ class DropSetCandidateResolverTest { programmedBaseWeightPerCableKg = base, minimumWeightPerCableKg = floor, commandTemplate = template, - hardwareModel = com.devil.phoenixproject.domain.model.PhoenixModel.TrainerPlus, + hardwareModel = hardwareModel, ), ) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/DropSetEligibilityPolicyTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/DropSetEligibilityPolicyTest.kt index 06890c29f..475235b2d 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/DropSetEligibilityPolicyTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/DropSetEligibilityPolicyTest.kt @@ -183,7 +183,7 @@ class DropSetEligibilityPolicyTest { configuration, expectedLiveIdentity, commandTemplate, - com.devil.phoenixproject.domain.model.PhoenixModel.TrainerPlus, + com.devil.phoenixproject.domain.model.PhoenixModel.Unknown, ) private fun completion( diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/RecommendWeightAdjustmentUseCaseTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/RecommendWeightAdjustmentUseCaseTest.kt index 92471959b..952ff56ec 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/RecommendWeightAdjustmentUseCaseTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/RecommendWeightAdjustmentUseCaseTest.kt @@ -11,6 +11,7 @@ import com.devil.phoenixproject.domain.model.RepQualityScore import com.devil.phoenixproject.domain.model.SetQualitySummary import com.devil.phoenixproject.domain.model.StrengthProfile import com.devil.phoenixproject.domain.model.VelocityResult +import com.devil.phoenixproject.domain.model.PhoenixModel import com.devil.phoenixproject.domain.model.WeightAdjustmentDirection import com.devil.phoenixproject.domain.model.WeightAdjustmentInput import kotlin.test.BeforeTest @@ -142,17 +143,29 @@ class RecommendWeightAdjustmentUseCaseTest { @Test fun recommendationSuppressesWhenIncreaseWouldExceedMaxWeight() { - val recommendation = useCase( + val trainerPlus = useCase( input( targetReps = 10, actualReps = 10, currentWeight = 110f, increment = 2.5f, quality = qualitySummary(scores = listOf(95, 92, 90)), + hardwareModel = PhoenixModel.TrainerPlus, ), ) + assertNull(trainerPlus) - assertNull(recommendation) + val vForm = useCase( + input( + targetReps = 10, + actualReps = 10, + currentWeight = 100f, + increment = 2.5f, + quality = qualitySummary(scores = listOf(95, 92, 90)), + hardwareModel = PhoenixModel.VFormTrainer, + ), + ) + assertNull(vForm) } @Test @@ -226,6 +239,7 @@ class RecommendWeightAdjustmentUseCaseTest { quality: SetQualitySummary? = qualitySummary(scores = listOf(90, 88, 86)), biomechanics: BiomechanicsSetSummary? = null, isBodyweight: Boolean = false, + hardwareModel: PhoenixModel = PhoenixModel.Unknown, ): WeightAdjustmentInput = WeightAdjustmentInput( exerciseId = "bench-press-001", exerciseName = "Bench Press", @@ -239,6 +253,7 @@ class RecommendWeightAdjustmentUseCaseTest { biomechanicsSummary = biomechanics, isBodyweight = isBodyweight, hasNextSetTarget = true, + hardwareModel = hardwareModel, ) private fun qualitySummary( diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/BulkWeightAdjustTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/BulkWeightAdjustTest.kt index 822108b71..c8338257b 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/BulkWeightAdjustTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/BulkWeightAdjustTest.kt @@ -51,7 +51,7 @@ class BulkWeightAdjustTest { private fun bulkAdjust( exercises: List, mode: BulkAdjustMode, - model: PhoenixModel = PhoenixModel.TrainerPlus, + model: PhoenixModel = PhoenixModel.Unknown, ) = applyBulkAdjust(exercises, mode, model) // ── Percentage mode ───────────────────────────────────────────── diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BlePacketFactoryTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BlePacketFactoryTest.kt index 607e1ac48..09c55b6d4 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BlePacketFactoryTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BlePacketFactoryTest.kt @@ -37,7 +37,7 @@ class BlePacketFactoryTest { private fun programParams( params: WorkoutParameters, - model: PhoenixModel = PhoenixModel.TrainerPlus, + model: PhoenixModel = PhoenixModel.Unknown, variant: BlePacketFactory.ForceConfigVariant = BlePacketFactory.defaultForceConfigVariant, ): ByteArray = BlePacketFactory.createProgramParams(params, model, variant) @@ -1585,7 +1585,7 @@ class BlePacketFactoryTest { assertEquals(0.0f, readFloatLE(packet, 0x4C), "OldSchool ecc.up.ramp at 0x4C") // Protocol force config (0x50-0x5F) - val forceMax = ChassisLimits.forceMaxKg(weightPerCableKg, PhoenixModel.TrainerPlus) + val forceMax = 90.0f assertEquals(0.0f, readFloatLE(packet, 0x50), "forceMin at 0x50 must be 0") assertEquals( @@ -1599,9 +1599,9 @@ class BlePacketFactoryTest { "targetWeight at 0x58 must be selected weight", ) assertEquals( - progressionKg, + (100f - 80f) / 9f, readFloatLE(packet, 0x5C), - "progression at 0x5C must be progressionKg", + "80 kg × 10 reps × +4.536 must clamp to V-Form/Unknown headroom, not encode 4.536", ) // Verify the weight is NOT near-zero (the actual bug symptom) @@ -1689,6 +1689,78 @@ class BlePacketFactoryTest { assertEquals(0x04.toByte(), packet[0], "CONFIG opcode must stay 0x04") } + @Test + fun `forceMax at 95 kg V-Form is chassis max not weight plus 10`() { + val params = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + weightPerCableKg = 95f, + ) + val packet = programParams(params, model = PhoenixModel.VFormTrainer) + assertEquals(95f, readFloatLE(packet, BleConstants.ActivationPacket.OFFSET_TARGET_WEIGHT)) + assertEquals( + 100f, + readFloatLE(packet, BleConstants.ActivationPacket.OFFSET_FORCE_MAX), + "95+10 would be 105; V-Form forceMax must cap at 100", + ) + } + + @Test + fun `forceMax at Trainer+ 110 start is 110 not 120`() { + val params = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + weightPerCableKg = 110f, + ) + val packet = programParams(params, model = PhoenixModel.TrainerPlus) + assertEquals(110f, readFloatLE(packet, BleConstants.ActivationPacket.OFFSET_TARGET_WEIGHT)) + assertEquals( + 110f, + readFloatLE(packet, BleConstants.ActivationPacket.OFFSET_FORCE_MAX), + "110+10 would be 120; Trainer+ forceMax must cap at 110", + ) + } + + @Test + fun `finite-rep progression is clamped to V-Form chassis headroom`() { + val params = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + weightPerCableKg = 100f, + progressionRegressionKg = 3f, + ) + val packet = programParams(params, model = PhoenixModel.VFormTrainer) + assertEquals( + 0f, + readFloatLE(packet, BleConstants.ActivationPacket.OFFSET_PROGRESSION), + "100 kg × 8 reps × +3 kg/rep must not encode +3 on V-Form", + ) + + val underCap = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + weightPerCableKg = 90f, + progressionRegressionKg = 5f, + ) + val underCapPacket = programParams(underCap, model = PhoenixModel.VFormTrainer) + val encoded = readFloatLE(underCapPacket, BleConstants.ActivationPacket.OFFSET_PROGRESSION) + assertTrue(encoded < 5f, "90 kg × +5 kg/rep on V-Form must not encode 5; got $encoded") + assertEquals(10f / 7f, encoded) + } + + @Test + fun `Just Lift leaves requested progression unclamped by finite-rep headroom`() { + val params = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + weightPerCableKg = 40f, + progressionRegressionKg = 3f, + isJustLift = true, + ) + val packet = programParams(params, model = PhoenixModel.VFormTrainer) + assertEquals(3f, readFloatLE(packet, BleConstants.ActivationPacket.OFFSET_PROGRESSION)) + } + @Test fun `forceMax is capped at Trainer+ chassis max`() { val params = WorkoutParameters( @@ -1735,9 +1807,21 @@ class BlePacketFactoryTest { ) assertEquals(32, packet.size) assertEquals(0x4E.toByte(), packet[0], "Echo opcode must stay 0x4E") - // 0x4E is timing/profile only; selected kg lives on CONFIG 0x04, not here. - for (offset in 0x50 until 0x60) { - assertTrue(offset >= packet.size, "Echo packet must not contain CONFIG force offsets") + assertEquals(0.1f, readFloatLE(packet, 0x0C), "concentricDelayS") + assertEquals(1.0f, readFloatLE(packet, 0x10), "HARD concentricDurationSeconds = 50/50") + assertEquals(50.0f, readFloatLE(packet, 0x14), "HARD concentricMaxVelocity") + assertEquals(0.0f, readFloatLE(packet, 0x18), "eccentricDurationSeconds") + assertEquals(-200.0f, readFloatLE(packet, 0x1C), "eccentricMaxVelocity") + val kgBitPatterns = listOf(100.5f, 110f, 105f).map { it.toRawBits() } + for (offset in 0 until 29) { + val bits = (packet[offset].toInt() and 0xFF) or + ((packet[offset + 1].toInt() and 0xFF) shl 8) or + ((packet[offset + 2].toInt() and 0xFF) shl 16) or + ((packet[offset + 3].toInt() and 0xFF) shl 24) + assertTrue( + bits !in kgBitPatterns, + "Echo 0x4E must not contain kg float bits at offset 0x${offset.toString(16)}", + ) } } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt index 3ed7efcea..da9f0388a 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt @@ -12,7 +12,7 @@ class WorkoutCommandValidatorTest { private fun validateParams( params: WorkoutParameters, - model: PhoenixModel = PhoenixModel.TrainerPlus, + model: PhoenixModel = PhoenixModel.Unknown, ) = WorkoutCommandValidator.validateProgramParams(params, model) @Test @@ -146,6 +146,9 @@ class WorkoutCommandValidatorTest { ChassisLimits.TRAINER_PLUS_KG_PER_CABLE, ChassisLimits.maxKgPerCable(PhoenixModel.TrainerPlus), ) + assertEquals(100f, HardwareDetection.getCapabilities("Phoenix").maxResistanceKg) + assertEquals(100f, HardwareDetection.getCapabilities("Vee_Test").maxResistanceKg) + assertEquals(110f, HardwareDetection.getCapabilities("VIT_Test").maxResistanceKg) } @Test From b039a77963cd5dfcb4d6a4cc72c9afa2ad4aae70 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 23 Aug 2026 14:59:48 -0400 Subject: [PATCH 3/5] fix: fail-closed scan and connect on Vee_/VIT names Extract BleAdvertisementFilter so startScanning matches scanAndConnect: connectable iff Vee_ or VIT. connect() re-checks identity before GATT and does not send CONFIG or RESET. Unnamed NUS/FEF3 may be listed, not auto-bound. Last-successful identifier remains an opt-in. --- .../data/ble/BleAdvertisementFilter.kt | 70 ++++++++ .../data/ble/KableBleConnectionManager.kt | 163 +++++++++--------- .../data/ble/BleAdvertisementFilterTest.kt | 139 +++++++++++++++ .../data/ble/KableBleConnectionManagerTest.kt | 90 ++++++++++ 4 files changed, 384 insertions(+), 78 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilter.kt create mode 100644 shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilterTest.kt diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilter.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilter.kt new file mode 100644 index 000000000..931d78642 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilter.kt @@ -0,0 +1,70 @@ +package com.devil.phoenixproject.data.ble + +import com.devil.phoenixproject.util.BleConstants + +/** + * Fail-closed BLE advertisement identity (D-12 / FP-2). + * + * Connectable names are `Vee_` (V-Form) or `VIT` (Trainer+), ignore-case. + * Generic `Vitruvian*` / `Phoenix*` names, empty names, nameless NUS, and + * FEF3-only advertisers are not connectable. Unnamed NUS/FEF3 may still be + * listed as visible-only and cannot [mayConnect] until a connectable name is + * observed, or the identifier matches the last successful connect (opt-in). + */ +object BleAdvertisementFilter { + const val FEF3_UUID_STRING = "0000fef3-0000-1000-8000-00805f9b34fb" + const val FEF3_UUID_PREFIX = "0000fef3" + + /** + * GATT-connectable iff the advertised name is a V-Form or Trainer+ prefix. + * `Vitruvian` starts with `VIT` and is **not** a Trainer+ advertisement. + */ + fun isConnectableName(name: String?): Boolean { + val n = name?.trim().orEmpty() + if (n.isEmpty()) return false + if (n.startsWith("Vee_", ignoreCase = true)) return true + if (!n.startsWith("VIT", ignoreCase = true)) return false + return !n.startsWith("Vitruvian", ignoreCase = true) + } + + fun hasTrainerServiceUuid(serviceUuidStrings: Collection): Boolean = serviceUuidStrings.any { uuid -> + val s = uuid.lowercase() + s.startsWith(FEF3_UUID_PREFIX) || + s == BleConstants.NUS_SERVICE_UUID_STRING.lowercase() + } + + /** + * Nameless NUS / FEF3 advertisers may appear in the scan list but cannot + * be auto-bound. Named non-trainer devices are not visible-only. + */ + fun isVisibleOnlyCandidate( + name: String?, + serviceUuidStrings: Collection, + hasFef3ServiceData: Boolean, + ): Boolean { + if (!name.isNullOrBlank()) return false + return hasTrainerServiceUuid(serviceUuidStrings) || hasFef3ServiceData + } + + fun shouldListDuringScan( + name: String?, + serviceUuidStrings: Collection, + hasFef3ServiceData: Boolean, + ): Boolean = isConnectableName(name) || + isVisibleOnlyCandidate(name, serviceUuidStrings, hasFef3ServiceData) + + /** + * [connect] re-check: a live name must be connectable, unless this + * identifier is the last successful trainer connection (opt-in). + */ + fun mayConnect( + name: String?, + identifier: String? = null, + lastSuccessfulIdentifier: String? = null, + ): Boolean { + if (isConnectableName(name)) return true + val id = identifier?.takeIf { it.isNotBlank() } ?: return false + val last = lastSuccessfulIdentifier?.takeIf { it.isNotBlank() } ?: return false + return id == last + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/KableBleConnectionManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/KableBleConnectionManager.kt index 3e44c2860..c448b8f27 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/KableBleConnectionManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/KableBleConnectionManager.kt @@ -171,6 +171,13 @@ class KableBleConnectionManager( /** Connected device address (for logging). */ private var connectedDeviceAddress: String = "" + /** + * Identifier of the last successful trainer GATT session. Unnamed NUS/FEF3 + * candidates may connect only when this matches (D-12 opt-in). + */ + @Volatile + private var lastSuccessfulIdentifier: String? = null + /** * Flag to track explicit disconnect (to avoid auto-reconnect). * @Volatile: set by disconnect()/cancelConnection() on the caller's @@ -224,6 +231,12 @@ class KableBleConnectionManager( LifecycleJob.STATE_OBSERVER -> stateObserverJob?.isActive == true } + internal fun lastSuccessfulIdentifierForTest(): String? = lastSuccessfulIdentifier + + internal fun rememberLastSuccessfulIdentifierForTest(identifier: String?) { + lastSuccessfulIdentifier = identifier + } + internal fun startFakeLifecycleJobsForTest() { scanJob?.cancel() scanJob = scope.launch { @@ -254,6 +267,34 @@ class KableBleConnectionManager( onConnectionStateChanged(state) } + private fun shouldListAdvertisement(advertisement: Advertisement): Boolean { + val name = advertisement.name + val uuidStrings = advertisement.uuids.map { it.toString() } + val hasFef3 = advertisementHasFef3ServiceData(advertisement) + val list = BleAdvertisementFilter.shouldListDuringScan(name, uuidStrings, hasFef3) + when { + BleAdvertisementFilter.isConnectableName(name) -> + log.i { "Found trainer by name: $name" } + + list -> + log.i { "Listing unnamed NUS/FEF3 candidate (not auto-bind): ${advertisement.identifier}" } + + name != null -> + log.d { "Ignoring device: $name (not Vee_/VIT)" } + } + return list + } + + private fun advertisementHasFef3ServiceData(advertisement: Advertisement): Boolean { + val fef3Uuid = try { + Uuid.parse(BleAdvertisementFilter.FEF3_UUID_STRING) + } catch (_: Exception) { + return false + } + val data = advertisement.serviceData(fef3Uuid) + return data != null && data.isNotEmpty() + } + private fun clearConnectionState(clearScannedDevices: Boolean = false) { peripheral = null connectedDeviceName = "" @@ -301,81 +342,23 @@ class KableBleConnectionManager( "RAW ADV: name=${advertisement.name}, id=${advertisement.identifier}, uuids=${advertisement.uuids}, rssi=${advertisement.rssi}" } } - .filter { advertisement -> - // Filter by name if available - val name = advertisement.name - if (name != null) { - val isPhoenix = name.startsWith("Vee_", ignoreCase = true) || - name.startsWith("VIT", ignoreCase = true) || - name.startsWith("Phoenix", ignoreCase = true) - if (isPhoenix) { - log.i { "Found Phoenix by name: $name" } - } else { - log.d { "Ignoring device: $name (not Phoenix)" } - } - return@filter isPhoenix - } - - // Check for Phoenix service UUIDs (mServiceUuids) - val serviceUuids = advertisement.uuids - val hasPhoenixServiceUuid = serviceUuids.any { uuid -> - val uuidStr = uuid.toString().lowercase() - uuidStr.startsWith("0000fef3") || - uuidStr == BleConstants.NUS_SERVICE_UUID_STRING - } - - if (hasPhoenixServiceUuid) { - log.i { "Found Phoenix by service UUID: ${advertisement.identifier}" } - return@filter true - } - - // CRITICAL: Check for FEF3 service data - // The Phoenix device advertises FEF3 in serviceData, not serviceUuids! - // In Kable, serviceData is accessed differently - try to get FEF3 directly - val fef3Uuid = try { - Uuid.parse("0000fef3-0000-1000-8000-00805f9b34fb") - } catch (_: Exception) { - null - } - - val hasPhoenixServiceData = if (fef3Uuid != null) { - // Try to get data for FEF3 service UUID - val fef3Data = advertisement.serviceData(fef3Uuid) - if (fef3Data != null && fef3Data.isNotEmpty()) { - log.i { - "Found Phoenix by FEF3 serviceData: ${advertisement.identifier}, data size: ${fef3Data.size}" - } - true - } else { - false - } - } else { - false - } - - hasPhoenixServiceData - } + .filter { advertisement -> shouldListAdvertisement(advertisement) } .onEach { advertisement -> @Suppress("REDUNDANT_CALL_OF_CONVERSION_METHOD") // Needed for iOS where identifier is Uuid val identifier = advertisement.identifier.toString() val advertisedName = advertisement.name - val hasRealName = advertisedName != null && - ( - advertisedName.startsWith("Vee_", ignoreCase = true) || - advertisedName.startsWith("VIT", ignoreCase = true) - ) + val hasRealName = BleAdvertisementFilter.isConnectableName(advertisedName) // Use name if available, otherwise use identifier as placeholder val name = advertisedName ?: "Trainer ($identifier)" - // Skip devices without a real Phoenix name if we already have one + // Skip unnamed NUS/FEF3 candidates if we already have a named trainer if (!hasRealName) { val alreadyHaveRealDevice = currentScannedDevices.any { existing -> - existing.name.startsWith("Vee_", ignoreCase = true) || - existing.name.startsWith("VIT", ignoreCase = true) + BleAdvertisementFilter.isConnectableName(existing.name) } if (alreadyHaveRealDevice) { - log.d { "Skipping nameless device $identifier - already have named Phoenix device" } + log.d { "Skipping nameless device $identifier - already have named trainer" } return@onEach } } @@ -407,8 +390,7 @@ class KableBleConnectionManager( // (same physical device can advertise with different identifiers) if (hasRealName) { devices = devices.filter { existing -> - existing.name.startsWith("Vee_", ignoreCase = true) || - existing.name.startsWith("VIT", ignoreCase = true) || + BleAdvertisementFilter.isConnectableName(existing.name) || existing.address == identifier // Keep if same address (will update below) }.toMutableList() } @@ -524,17 +506,11 @@ class KableBleConnectionManager( discoveredAdvertisements.clear() return try { - // Find first Phoenix device with a real name + // First named trainer wins. Unnamed NUS/FEF3 are not auto-bound. val advertisement = withTimeoutOrNull(timeoutMs) { Scanner {} .advertisements - .filter { adv -> - val name = adv.name - name != null && ( - name.startsWith("Vee_", ignoreCase = true) || - name.startsWith("VIT", ignoreCase = true) - ) - } + .filter { adv -> BleAdvertisementFilter.isConnectableName(adv.name) } .first() } @@ -570,6 +546,29 @@ class KableBleConnectionManager( // ------------------------------------------------------------------------- suspend fun connect(device: ScannedDevice): Result { + val advertisementForIdentity = discoveredAdvertisements[device.address] + val advertisedName = advertisementForIdentity?.name + val allowed = BleAdvertisementFilter.mayConnect( + name = device.name, + identifier = device.address, + lastSuccessfulIdentifier = lastSuccessfulIdentifier, + ) || BleAdvertisementFilter.mayConnect( + name = advertisedName, + identifier = device.address, + lastSuccessfulIdentifier = lastSuccessfulIdentifier, + ) + if (!allowed) { + log.w { "Rejecting connect: '${device.name}' is not Vee_/VIT" } + logRepo.warning( + LogEventType.CONNECT_FAIL, + "Rejecting connect: name fails Vee_/VIT predicate", + device.name, + device.address, + ) + reportConnectionState(ConnectionState.Disconnected) + return Result.failure(IllegalArgumentException("Not a trainer device: ${device.name}")) + } + log.i { "Connecting to device: ${device.name}" } logRepo.info( LogEventType.CONNECT_START, @@ -591,7 +590,7 @@ class KableBleConnectionManager( reportConnectionState(ConnectionState.Connecting) - val advertisement = discoveredAdvertisements[device.address] + val advertisement = advertisementForIdentity if (advertisement == null) { log.e { "Advertisement not found for device: ${device.address}" } logRepo.error( @@ -669,6 +668,7 @@ class KableBleConnectionManager( ) return@launch } + lastSuccessfulIdentifier = device.address reportConnectionState( ConnectionState.Connected( deviceName = device.name, @@ -825,7 +825,11 @@ class KableBleConnectionManager( // Finding [49]: disconnect before retry — Kable Peripheral may still be // in State.Connecting after a timeout; a fresh connect() on a half-open // Peripheral can wedge the GATT stack. Disconnect first to reset state. - try { peripheral?.disconnect() } catch (e: Exception) { e.rethrowIfCancellation() } + try { + peripheral?.disconnect() + } catch (e: Exception) { + e.rethrowIfCancellation() + } delay(BleConstants.Timing.CONNECTION_RETRY_DELAY_MS) } } catch (e: BleDeviceInitializationException) { @@ -841,7 +845,11 @@ class KableBleConnectionManager( if (attempt < BleConstants.Timing.CONNECTION_RETRY_COUNT) { // Finding [49]: disconnect before retry to ensure Kable Peripheral // is not stuck in State.Connecting before the next connect() call. - try { peripheral?.disconnect() } catch (e2: Exception) { e2.rethrowIfCancellation() } + try { + peripheral?.disconnect() + } catch (e2: Exception) { + e2.rethrowIfCancellation() + } delay(BleConstants.Timing.CONNECTION_RETRY_DELAY_MS) } } @@ -1245,8 +1253,7 @@ class KableBleConnectionManager( * Exponential backoff (ms) for reps resubscribe attempts: 100, 200, 400, 800, * capped at [BleConstants.Timing.REPS_SUBSCRIBE_BACKOFF_MAX_MS]. */ - internal fun repsBackoffMs(attempt: Int): Long = - // coerceIn(0, 30) guards the shift amount: a stray large attempt can never + internal fun repsBackoffMs(attempt: Int): Long = // coerceIn(0, 30) guards the shift amount: a stray large attempt can never // wrap the Long shift (Kotlin shifts mod 64); 30 bits is far beyond the cap. (BleConstants.Timing.REPS_SUBSCRIBE_BACKOFF_BASE_MS shl (attempt - 1).coerceIn(0, 30)) .coerceAtMost(BleConstants.Timing.REPS_SUBSCRIBE_BACKOFF_MAX_MS) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilterTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilterTest.kt new file mode 100644 index 000000000..4867fccf6 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilterTest.kt @@ -0,0 +1,139 @@ +package com.devil.phoenixproject.data.ble + +import com.devil.phoenixproject.util.BleConstants +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Production predicate for scan/connect fail-closed (D-12 / FP-2). + * No hardware; [FakeBleRepository.simulateConnect] is not the subject. + */ +class BleAdvertisementFilterTest { + + @Test + fun `accepts Vee_ and VIT trainer names`() { + assertTrue(BleAdvertisementFilter.isConnectableName("Vee_foo")) + assertTrue(BleAdvertisementFilter.isConnectableName("vee_FOO")) + assertTrue(BleAdvertisementFilter.isConnectableName("VITBAR")) + assertTrue(BleAdvertisementFilter.isConnectableName("vitbar")) + assertTrue(BleAdvertisementFilter.isConnectableName("VIT-200")) + assertTrue(BleAdvertisementFilter.isConnectableName("VIT")) + assertTrue(BleAdvertisementFilter.mayConnect("Vee_foo")) + assertTrue(BleAdvertisementFilter.mayConnect("VITBAR")) + } + + @Test + fun `rejects placeholder empty Vitruvian Phoenix and Vee without underscore`() { + assertFalse(BleAdvertisementFilter.isConnectableName("Trainer (AA:BB:CC:DD:EE:FF)")) + assertFalse(BleAdvertisementFilter.isConnectableName("")) + assertFalse(BleAdvertisementFilter.isConnectableName(" ")) + assertFalse(BleAdvertisementFilter.isConnectableName(null)) + assertFalse(BleAdvertisementFilter.isConnectableName("Vitruvian")) + assertFalse(BleAdvertisementFilter.isConnectableName("Vitruvian Form")) + assertFalse(BleAdvertisementFilter.isConnectableName("Phoenix")) + assertFalse(BleAdvertisementFilter.isConnectableName("Phoenix_Gym")) + assertFalse(BleAdvertisementFilter.isConnectableName("Vee")) + assertFalse(BleAdvertisementFilter.isConnectableName("VeeFoo")) + assertFalse(BleAdvertisementFilter.mayConnect("Trainer (AA:BB:CC:DD:EE:FF)")) + assertFalse(BleAdvertisementFilter.mayConnect("")) + assertFalse(BleAdvertisementFilter.mayConnect("Vitruvian")) + } + + @Test + fun `rejects nameless NUS and FEF3-only as connectable`() { + val nus = listOf(BleConstants.NUS_SERVICE_UUID_STRING) + val fef3 = listOf(BleAdvertisementFilter.FEF3_UUID_STRING) + + assertFalse(BleAdvertisementFilter.isConnectableName(null)) + assertFalse(BleAdvertisementFilter.mayConnect(name = null, identifier = "AA:BB")) + assertFalse( + BleAdvertisementFilter.mayConnect( + name = null, + identifier = "AA:BB", + lastSuccessfulIdentifier = null, + ), + ) + assertTrue(BleAdvertisementFilter.hasTrainerServiceUuid(nus)) + assertTrue(BleAdvertisementFilter.hasTrainerServiceUuid(fef3)) + assertTrue( + BleAdvertisementFilter.isVisibleOnlyCandidate( + name = null, + serviceUuidStrings = nus, + hasFef3ServiceData = false, + ), + ) + assertTrue( + BleAdvertisementFilter.isVisibleOnlyCandidate( + name = null, + serviceUuidStrings = emptyList(), + hasFef3ServiceData = true, + ), + ) + assertFalse( + BleAdvertisementFilter.isVisibleOnlyCandidate( + name = "Vitruvian", + serviceUuidStrings = nus, + hasFef3ServiceData = true, + ), + ) + } + + @Test + fun `scan lists connectable names and unnamed NUS FEF3 but not Vitruvian`() { + val nus = listOf(BleConstants.NUS_SERVICE_UUID_STRING) + assertTrue( + BleAdvertisementFilter.shouldListDuringScan("Vee_foo", emptyList(), false), + ) + assertTrue( + BleAdvertisementFilter.shouldListDuringScan("VITBAR", emptyList(), false), + ) + assertFalse( + BleAdvertisementFilter.shouldListDuringScan("Vitruvian", nus, true), + ) + assertFalse( + BleAdvertisementFilter.shouldListDuringScan("Phoenix", nus, false), + ) + assertFalse( + BleAdvertisementFilter.shouldListDuringScan("Trainer (addr)", nus, false), + ) + assertTrue( + BleAdvertisementFilter.shouldListDuringScan(null, nus, false), + ) + assertTrue( + BleAdvertisementFilter.shouldListDuringScan( + name = null, + serviceUuidStrings = emptyList(), + hasFef3ServiceData = true, + ), + ) + assertFalse( + BleAdvertisementFilter.shouldListDuringScan(null, emptyList(), false), + ) + } + + @Test + fun `last-successful identifier is opt-in connect for placeholder names`() { + assertTrue( + BleAdvertisementFilter.mayConnect( + name = "Trainer (AA:BB)", + identifier = "AA:BB", + lastSuccessfulIdentifier = "AA:BB", + ), + ) + assertFalse( + BleAdvertisementFilter.mayConnect( + name = "Trainer (AA:BB)", + identifier = "AA:BB", + lastSuccessfulIdentifier = "CC:DD", + ), + ) + assertFalse( + BleAdvertisementFilter.mayConnect( + name = "Trainer (AA:BB)", + identifier = "AA:BB", + lastSuccessfulIdentifier = null, + ), + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/KableBleConnectionManagerTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/KableBleConnectionManagerTest.kt index 2ac2acd75..30a253235 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/KableBleConnectionManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/KableBleConnectionManagerTest.kt @@ -1,12 +1,14 @@ package com.devil.phoenixproject.data.ble import com.devil.phoenixproject.data.repository.ConnectionLogRepository +import com.devil.phoenixproject.data.repository.LogEventType import com.devil.phoenixproject.data.repository.ReconnectionRequest import com.devil.phoenixproject.data.repository.ScannedDevice import com.devil.phoenixproject.domain.model.ConnectionState import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.CoroutineScope @@ -264,6 +266,94 @@ class KableBleConnectionManagerTest { assertTrue(packet.receivedAtMillis > 0L) } + // ========================================================================= + // connect() fail-closed identity (D-12 / FP-2) + // ========================================================================= + + @Test + fun `connect rejects non-trainer names without sending CONFIG`() = runTest { + val logRepo = ConnectionLogRepository.instance + logRepo.clearAll() + val (manager, tracker) = createTestManager() + val rejected = listOf( + ScannedDevice("Vitruvian", "AA:BB:CC:DD:EE:01", -40), + ScannedDevice("Trainer (AA:BB:CC:DD:EE:02)", "AA:BB:CC:DD:EE:02", -40), + ScannedDevice("", "AA:BB:CC:DD:EE:03", -40), + ScannedDevice("Phoenix", "AA:BB:CC:DD:EE:04", -40), + ) + + for (device in rejected) { + val result = manager.connect(device) + assertTrue(result.isFailure, "connect(${device.name}) should fail closed") + assertIs(result.exceptionOrNull()) + assertTrue( + result.exceptionOrNull()?.message?.contains("Not a trainer device") == true, + result.exceptionOrNull()?.message, + ) + } + + assertNull(manager.currentPeripheral) + assertTrue(tracker.connectionStates.none { it is ConnectionState.Connected }) + assertTrue(tracker.connectionStates.none { it is ConnectionState.Connecting }) + assertEquals(ConnectionState.Disconnected, tracker.connectionStates.last()) + + val config = ByteArray(96).also { it[0] = 0x04 } + val send = manager.sendWorkoutCommand(config) + assertTrue(send.isFailure, "CONFIG must not be sent after identity reject") + assertEquals("Not connected", send.exceptionOrNull()?.message) + + val rejects = logRepo.getLogsByEventType(LogEventType.CONNECT_FAIL) + assertTrue( + rejects.any { it.message.contains("Vee_/VIT") }, + "Reject-on-connect must be logged", + ) + } + + @Test + fun `connect allows Vee_ and VIT names past identity and does not RESET`() = runTest { + val (manager, tracker) = createTestManager() + + val missingAdv = manager.connect(ScannedDevice("Vee_foo", "AA:BB:CC:DD:EE:10", -40)) + assertTrue(missingAdv.isFailure) + assertEquals( + "Device not found in scanned list", + missingAdv.exceptionOrNull()?.message, + ) + + val vitMissing = manager.connect(ScannedDevice("VITBAR", "AA:BB:CC:DD:EE:11", -40)) + assertTrue(vitMissing.isFailure) + assertEquals( + "Device not found in scanned list", + vitMissing.exceptionOrNull()?.message, + ) + + assertNull(manager.currentPeripheral) + assertTrue(tracker.connectionStates.any { it is ConnectionState.Connecting }) + assertTrue(tracker.connectionStates.none { it is ConnectionState.Connected }) + assertEquals(ConnectionState.Disconnected, tracker.connectionStates.last()) + val reset = ByteArray(4).also { it[0] = 0x0A } + val send = manager.sendWorkoutCommand(reset) + assertTrue(send.isFailure) + } + + @Test + fun `connect last-successful identifier is opt-in for placeholder names`() = runTest { + val (manager, _) = createTestManager() + val placeholder = ScannedDevice("Trainer (AA:BB:CC:DD:EE:FF)", "AA:BB:CC:DD:EE:FF", -50) + + val rejected = manager.connect(placeholder) + assertIs(rejected.exceptionOrNull()) + + manager.rememberLastSuccessfulIdentifierForTest("AA:BB:CC:DD:EE:FF") + assertEquals("AA:BB:CC:DD:EE:FF", manager.lastSuccessfulIdentifierForTest()) + val optedIn = manager.connect(placeholder) + assertEquals( + "Device not found in scanned list", + optedIn.exceptionOrNull()?.message, + ) + assertNull(manager.currentPeripheral) + } + // ========================================================================= // cancelConnection State Cleanup (2 tests) // ========================================================================= From 755524bece80d8c094647432e5fab92215051a2a Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 30 Aug 2026 20:47:52 -0400 Subject: [PATCH 4/5] fix: close chassis and BLE identity gaps --- .../data/ble/BleAdvertisementFilter.kt | 29 +++++++++- .../data/ble/KableBleConnectionManager.kt | 28 +++++++--- .../manager/ActiveSessionEngine.kt | 30 ++++++---- .../phoenixproject/util/HardwareDetection.kt | 3 +- .../data/ble/BleAdvertisementFilterTest.kt | 55 +++++++++++++++++++ .../util/WorkoutCommandValidatorTest.kt | 7 +++ 6 files changed, 128 insertions(+), 24 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilter.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilter.kt index 931d78642..f312837bd 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilter.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilter.kt @@ -54,8 +54,29 @@ object BleAdvertisementFilter { isVisibleOnlyCandidate(name, serviceUuidStrings, hasFef3ServiceData) /** - * [connect] re-check: a live name must be connectable, unless this - * identifier is the last successful trainer connection (opt-in). + * Both the caller's scanned label and the stored advertisement must be + * independently admissible. This prevents a stale connectable UI label from + * authorizing a non-connectable advertisement for the same identifier. + */ + fun mayConnectWithAdvertisementIdentity( + scannedName: String?, + advertisedName: String?, + identifier: String?, + lastSuccessfulIdentifier: String? = null, + ): Boolean = mayConnect( + name = scannedName, + identifier = identifier, + lastSuccessfulIdentifier = lastSuccessfulIdentifier, + ) && mayConnect( + name = advertisedName, + identifier = identifier, + lastSuccessfulIdentifier = lastSuccessfulIdentifier, + ) + + /** + * [connect] re-check: a live name must be connectable. The last successful + * identifier is an opt-in only for unnamed advertisements represented by the + * manager's generated `Trainer ()` placeholder. */ fun mayConnect( name: String?, @@ -63,6 +84,10 @@ object BleAdvertisementFilter { lastSuccessfulIdentifier: String? = null, ): Boolean { if (isConnectableName(name)) return true + val normalizedName = name?.trim().orEmpty() + val isUnnamedPlaceholder = normalizedName.isEmpty() || + (normalizedName.startsWith("Trainer (", ignoreCase = true) && normalizedName.endsWith(")")) + if (!isUnnamedPlaceholder) return false val id = identifier?.takeIf { it.isNotBlank() } ?: return false val last = lastSuccessfulIdentifier?.takeIf { it.isNotBlank() } ?: return false return id == last diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/KableBleConnectionManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/KableBleConnectionManager.kt index c448b8f27..f1031ae52 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/KableBleConnectionManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/KableBleConnectionManager.kt @@ -548,15 +548,25 @@ class KableBleConnectionManager( suspend fun connect(device: ScannedDevice): Result { val advertisementForIdentity = discoveredAdvertisements[device.address] val advertisedName = advertisementForIdentity?.name - val allowed = BleAdvertisementFilter.mayConnect( - name = device.name, - identifier = device.address, - lastSuccessfulIdentifier = lastSuccessfulIdentifier, - ) || BleAdvertisementFilter.mayConnect( - name = advertisedName, - identifier = device.address, - lastSuccessfulIdentifier = lastSuccessfulIdentifier, - ) + val allowed = if (advertisementForIdentity != null) { + // Once an advertisement is stored, its live identity is authoritative: + // a stale/connectable ScannedDevice label must not bypass this check. + BleAdvertisementFilter.mayConnectWithAdvertisementIdentity( + scannedName = device.name, + advertisedName = advertisedName, + identifier = device.address, + lastSuccessfulIdentifier = lastSuccessfulIdentifier, + ) + } else { + // Preserve the existing fail-closed "missing advertisement" result for + // direct callers; no Peripheral/GATT is created until an advertisement + // is present and independently validated above. + BleAdvertisementFilter.mayConnect( + name = device.name, + identifier = device.address, + lastSuccessfulIdentifier = lastSuccessfulIdentifier, + ) + } if (!allowed) { log.w { "Rejecting connect: '${device.name}' is not Vee_/VIT" } logRepo.warning( 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 f752393a6..adaa800b4 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 @@ -8253,20 +8253,26 @@ class ActiveSessionEngine( ?: resolveSelectedExercise(effectiveParams)?.preferredCableCount ?: 1 val rackSnapshotItems = coordinator._currentRackLoadAdjustment.value.selectedItems - val preRackValidation = if (warmupOverrideParams.isEchoMode) { - Result.success(Unit) - } else { - WorkoutCommandValidator.validateProgramParams(warmupOverrideParams, chassisModel()) - } - preRackValidation.onFailure { error -> - Logger.e(error) { "Invalid BLE workout command parameters: ${error.message}" } - coordinator._bleErrorEvents.tryEmit("Invalid BLE workout command: ${error.message}") - if (retryRequest != null) { - failRetryStartAndRecover(retryRequest, lease, priorWorkoutState) + // Direct/non-routine inputs have no legacy routine clamp path, so reject + // over-ceiling values before any BLE work. Routine values may be above + // a newly detected chassis ceiling in persisted data; preserve the + // existing rack adjustment clamp and validate the final BLE params below. + if (coordinator._loadedRoutine.value == null) { + val preRackValidation = if (warmupOverrideParams.isEchoMode) { + Result.success(Unit) } else { - failStart(lease, priorWorkoutState) + WorkoutCommandValidator.validateProgramParams(warmupOverrideParams, chassisModel()) + } + preRackValidation.onFailure { error -> + Logger.e(error) { "Invalid BLE workout command parameters: ${error.message}" } + coordinator._bleErrorEvents.tryEmit("Invalid BLE workout command: ${error.message}") + if (retryRequest != null) { + failRetryStartAndRecover(retryRequest, lease, priorWorkoutState) + } else { + failStart(lease, priorWorkoutState) + } + return@launch } - return@launch } val bleParams = run { val base = if (isTimedCableExercise) { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/HardwareDetection.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/HardwareDetection.kt index eed5bfd4e..bf8b27276 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/HardwareDetection.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/HardwareDetection.kt @@ -21,7 +21,8 @@ object HardwareDetection { */ fun detectModel(deviceName: String): PhoenixModel = when { deviceName.startsWith("Vee_", ignoreCase = true) -> PhoenixModel.VFormTrainer - deviceName.startsWith("VIT", ignoreCase = true) -> PhoenixModel.TrainerPlus + deviceName.startsWith("VIT", ignoreCase = true) && + !deviceName.startsWith("Vitruvian", ignoreCase = true) -> PhoenixModel.TrainerPlus else -> PhoenixModel.Unknown } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilterTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilterTest.kt index 4867fccf6..af921fd9c 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilterTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilterTest.kt @@ -112,6 +112,61 @@ class BleAdvertisementFilterTest { ) } + @Test + fun `stored advertisement identity must pass independently of scanned label`() { + assertTrue( + BleAdvertisementFilter.mayConnectWithAdvertisementIdentity( + scannedName = "Vee_scan", + advertisedName = "VIT_live", + identifier = "AA:BB", + lastSuccessfulIdentifier = null, + ), + ) + assertFalse( + BleAdvertisementFilter.mayConnectWithAdvertisementIdentity( + scannedName = "Vee_scan", + advertisedName = "Phoenix_live", + identifier = "AA:BB", + lastSuccessfulIdentifier = null, + ), + "A connectable ScannedDevice label must not bypass a rejected stored advertisement", + ) + assertFalse( + BleAdvertisementFilter.mayConnectWithAdvertisementIdentity( + scannedName = "Vee_scan", + advertisedName = "Vitruvian", + identifier = "AA:BB", + lastSuccessfulIdentifier = null, + ), + ) + assertTrue( + BleAdvertisementFilter.mayConnectWithAdvertisementIdentity( + scannedName = "Trainer (AA:BB)", + advertisedName = null, + identifier = "AA:BB", + lastSuccessfulIdentifier = "AA:BB", + ), + ) + } + + @Test + fun `last-successful identifier does not authorize named generic advertisements`() { + assertFalse( + BleAdvertisementFilter.mayConnect( + name = "Phoenix", + identifier = "AA:BB", + lastSuccessfulIdentifier = "AA:BB", + ), + ) + assertFalse( + BleAdvertisementFilter.mayConnect( + name = "Vitruvian", + identifier = "AA:BB", + lastSuccessfulIdentifier = "AA:BB", + ), + ) + } + @Test fun `last-successful identifier is opt-in connect for placeholder names`() { assertTrue( diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt index da9f0388a..3b0acd6b6 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt @@ -151,6 +151,13 @@ class WorkoutCommandValidatorTest { assertEquals(110f, HardwareDetection.getCapabilities("VIT_Test").maxResistanceKg) } + @Test + fun `Vitruvian names remain unknown and fail closed`() { + assertEquals(PhoenixModel.Unknown, HardwareDetection.detectModel("Vitruvian")) + assertEquals(PhoenixModel.Unknown, HardwareDetection.detectModel("Vitruvian Form")) + assertEquals(PhoenixModel.Unknown, HardwareDetection.detectModel("VITruvian")) + } + @Test fun `just lift requires minimum nonzero weight`() { assertFailureContains( From a450817207e301a3c8b9e41080647fc314156841 Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 30 Aug 2026 21:07:10 -0400 Subject: [PATCH 5/5] fix: address chassis BLE review findings --- .../data/ble/BleAdvertisementFilter.kt | 33 ++++++++++++++----- .../data/ble/KableBleConnectionManager.kt | 6 ++++ .../components/BulkWeightAdjustDialog.kt | 2 +- .../phoenixproject/util/ChassisLimits.kt | 4 ++- .../data/ble/BleAdvertisementFilterTest.kt | 10 ++++++ .../util/WorkoutCommandValidatorTest.kt | 16 +++++++++ 6 files changed, 60 insertions(+), 11 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilter.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilter.kt index f312837bd..1ba311b51 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilter.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilter.kt @@ -57,21 +57,36 @@ object BleAdvertisementFilter { * Both the caller's scanned label and the stored advertisement must be * independently admissible. This prevents a stale connectable UI label from * authorizing a non-connectable advertisement for the same identifier. + * Unnamed stored advertisements additionally require visible-only NUS/FEF3 + * evidence before the last-successful-identifier opt-in can apply. */ fun mayConnectWithAdvertisementIdentity( scannedName: String?, advertisedName: String?, identifier: String?, lastSuccessfulIdentifier: String? = null, - ): Boolean = mayConnect( - name = scannedName, - identifier = identifier, - lastSuccessfulIdentifier = lastSuccessfulIdentifier, - ) && mayConnect( - name = advertisedName, - identifier = identifier, - lastSuccessfulIdentifier = lastSuccessfulIdentifier, - ) + storedAdvertisementIsVisibleOnly: Boolean = false, + ): Boolean { + val scannedAllowed = mayConnect( + name = scannedName, + identifier = identifier, + lastSuccessfulIdentifier = lastSuccessfulIdentifier, + ) + val advertisedAllowed = if (advertisedName.isNullOrBlank()) { + storedAdvertisementIsVisibleOnly && mayConnect( + name = advertisedName, + identifier = identifier, + lastSuccessfulIdentifier = lastSuccessfulIdentifier, + ) + } else { + mayConnect( + name = advertisedName, + identifier = identifier, + lastSuccessfulIdentifier = lastSuccessfulIdentifier, + ) + } + return scannedAllowed && advertisedAllowed + } /** * [connect] re-check: a live name must be connectable. The last successful diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/KableBleConnectionManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/KableBleConnectionManager.kt index f1031ae52..6fc7e5b37 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/KableBleConnectionManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/ble/KableBleConnectionManager.kt @@ -551,11 +551,17 @@ class KableBleConnectionManager( val allowed = if (advertisementForIdentity != null) { // Once an advertisement is stored, its live identity is authoritative: // a stale/connectable ScannedDevice label must not bypass this check. + val storedAdvertisementIsVisibleOnly = BleAdvertisementFilter.isVisibleOnlyCandidate( + name = advertisedName, + serviceUuidStrings = advertisementForIdentity.uuids.map { it.toString() }, + hasFef3ServiceData = advertisementHasFef3ServiceData(advertisementForIdentity), + ) BleAdvertisementFilter.mayConnectWithAdvertisementIdentity( scannedName = device.name, advertisedName = advertisedName, identifier = device.address, lastSuccessfulIdentifier = lastSuccessfulIdentifier, + storedAdvertisementIsVisibleOnly = storedAdvertisementIsVisibleOnly, ) } else { // Preserve the existing fail-closed "missing advertisement" result for diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/BulkWeightAdjustDialog.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/BulkWeightAdjustDialog.kt index c3ff830ff..4f61f2ccc 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/BulkWeightAdjustDialog.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/BulkWeightAdjustDialog.kt @@ -180,7 +180,7 @@ fun BulkWeightAdjustDialog( } // Compute preview - val preview by remember(currentMode, exercises) { + val preview by remember(currentMode, exercises, hardwareModel) { derivedStateOf { currentMode?.let { mode -> applyBulkAdjust(exercises, mode, hardwareModel) } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/ChassisLimits.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/ChassisLimits.kt index 0997a506a..85137afb6 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/ChassisLimits.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/ChassisLimits.kt @@ -46,7 +46,9 @@ object ChassisLimits { model: PhoenixModel, unlimitedReps: Boolean, ): Float { - if (!requestedKg.isFinite()) return 0f + if (!requestedKg.isFinite()) { + throw IllegalArgumentException("requestedKg must be finite") + } if (unlimitedReps || requestedKg <= 0f) return requestedKg val steps = max(reps - 1, 1) val headroom = ((maxKgPerCable(model) - weightPerCableKg) / steps).coerceAtLeast(0f) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilterTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilterTest.kt index af921fd9c..3805555b4 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilterTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/BleAdvertisementFilterTest.kt @@ -139,12 +139,22 @@ class BleAdvertisementFilterTest { lastSuccessfulIdentifier = null, ), ) + assertFalse( + BleAdvertisementFilter.mayConnectWithAdvertisementIdentity( + scannedName = "Trainer (AA:BB)", + advertisedName = null, + identifier = "AA:BB", + lastSuccessfulIdentifier = "AA:BB", + ), + "A null-name stored advertisement must not pass on identifier alone", + ) assertTrue( BleAdvertisementFilter.mayConnectWithAdvertisementIdentity( scannedName = "Trainer (AA:BB)", advertisedName = null, identifier = "AA:BB", lastSuccessfulIdentifier = "AA:BB", + storedAdvertisementIsVisibleOnly = true, ), ) } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt index 3b0acd6b6..42b4d58b9 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt @@ -6,6 +6,7 @@ import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.WorkoutParameters import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue class WorkoutCommandValidatorTest { @@ -158,6 +159,21 @@ class WorkoutCommandValidatorTest { assertEquals(PhoenixModel.Unknown, HardwareDetection.detectModel("VITruvian")) } + @Test + fun `finite progression rejects non-finite input instead of rewriting it`() { + listOf(Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY).forEach { value -> + assertFailsWith { + ChassisLimits.finiteRepProgressionKg( + requestedKg = value, + weightPerCableKg = 20f, + reps = 8, + model = PhoenixModel.Unknown, + unlimitedReps = false, + ) + } + } + } + @Test fun `just lift requires minimum nonzero weight`() { assertFailureContains(