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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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.TestScope
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 {
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<String>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
harness.coordinator.bleErrorEvents.collect(bleErrors::add)
}
try {
harness.fakeBleRepo.simulateConnect(deviceName)
assertEquals(expectedModel, HardwareDetection.detectModel(deviceName))
startJustLift(harness, weightPerCableKg)
advanceUntilIdle()
assertTrue(
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(weightPerCableKg.toString()) },
"Expected send-site rejection of $weightPerCableKg kg on $deviceName, got $bleErrors",
)
} finally {
harness.cleanup()
}
}

private fun TestScope.assertAcceptedConfig(
deviceName: String,
expectedModel: PhoenixModel,
weightPerCableKg: Float,
): ByteArray {
val harness = DWSMTestHarness(this)
try {
harness.fakeBleRepo.simulateConnect(deviceName)
assertEquals(expectedModel, HardwareDetection.detectModel(deviceName))
startJustLift(harness, weightPerCableKg)
advanceUntilIdle()
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<ByteArray> =
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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<String>): 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<String>,
hasFef3ServiceData: Boolean,
): Boolean {
if (!name.isNullOrBlank()) return false
return hasTrainerServiceUuid(serviceUuidStrings) || hasFef3ServiceData
}

fun shouldListDuringScan(
name: String?,
serviceUuidStrings: Collection<String>,
hasFef3ServiceData: Boolean,
): Boolean = isConnectableName(name) ||
isVisibleOnlyCandidate(name, serviceUuidStrings, hasFef3ServiceData)

/**
* 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,
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
* identifier is an opt-in only for unnamed advertisements represented by the
* manager's generated `Trainer (<identifier>)` placeholder.
*/
fun mayConnect(
name: String?,
identifier: String? = null,
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
}
}
Loading
Loading