1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-10-29 06:36:56 +01:00

Convert BLE measurement values to user-defined units before saving

This change ensures that raw values received from Bluetooth scales (e.g., weight in KG, body fat in %) are converted to the units specified by the user in the MeasurementType settings before being persisted to the database.
This commit is contained in:
oliexdev
2025-09-02 19:35:25 +02:00
parent b6df901c50
commit 9e467410ac

View File

@@ -29,6 +29,7 @@ import com.health.openscale.core.data.ConnectionStatus
import com.health.openscale.core.data.Measurement
import com.health.openscale.core.data.MeasurementTypeKey
import com.health.openscale.core.data.MeasurementValue
import com.health.openscale.core.data.UnitType
import com.health.openscale.core.database.DatabaseRepository
import com.health.openscale.core.facade.MeasurementFacade
import com.health.openscale.core.utils.LogManager
@@ -414,129 +415,155 @@ class BleConnector(
}
/**
* Saves a [com.health.openscale.core.bluetooth.data.ScaleMeasurement] received from a device to the database.
* This involves creating a [com.health.openscale.core.data.Measurement] entity and associated [com.health.openscale.core.data.MeasurementValue]s.
* Saves a [com.health.openscale.core.bluetooth.data.ScaleMeasurement] received from a device to the DB.
*
* @param measurementData The raw measurement data from the scale.
* @param deviceAddress The address of the device that sent the measurement.
* @param deviceName The name of the device.
* ## What this does
* 1) Validates that an app user is selected.
* 2) Builds a `Measurement` row and associated `MeasurementValue` rows.
* 3) **Converts units** from the scale's **raw units** into the **target display units**
* defined by each [`MeasurementType`]'s `unit` field before persisting.
*
* ### Raw units assumed for ScaleMeasurement
* - WEIGHT, BONE, LBM → **KG**
* - BODY_FAT, WATER, MUSCLE, VISCERAL_FAT → **PERCENT**
*
* Other fields in `ScaleMeasurement` (if added later) should be appended here with the correct raw unit.
*
* @param measurementData Raw measurement from the scale (weight etc.)
* @param deviceAddress Address of the device that sent the measurement (for logging/UX).
* @param deviceName Human-friendly device name (for snackbar/logging).
*/
private suspend fun saveMeasurementFromEvent(measurementData: ScaleMeasurement, deviceAddress: String, deviceName: String) {
private suspend fun saveMeasurementFromEvent(
measurementData: ScaleMeasurement,
deviceAddress: String,
deviceName: String
) {
val currentAppUserId = getCurrentScaleUser()?.id
if (currentAppUserId == 0) {
LogManager.e(TAG, "($deviceName): No App User ID to save measurement.")
_snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_measurement_user_missing, messageFormatArgs = listOf(deviceName)))
_snackbarEvents.tryEmit(
SnackbarEvent(
messageResId = R.string.bluetooth_connector_measurement_user_missing,
messageFormatArgs = listOf(deviceName)
)
)
return
}
LogManager.i(TAG, "($deviceName): Saving measurement for App User ID $currentAppUserId.")
// This logic is largely identical to what might be in a ViewModel and could
// potentially be moved entirely into a dedicated MeasurementRepository or similar service.
scope.launch(Dispatchers.IO) { // Perform database operations on IO dispatcher.
// Perform DB work on IO dispatcher.
scope.launch(Dispatchers.IO) {
val newDbMeasurement = Measurement(
userId = currentAppUserId ?: 0,
timestamp = measurementData.dateTime?.time ?: System.currentTimeMillis()
)
// Fetch measurement type IDs from the database to map keys to foreign keys.
val typeKeyToIdMap: Map<MeasurementTypeKey, Int> =
measurementFacade.getAllMeasurementTypes().firstOrNull()
?.associate { it.key to it.id } ?: run {
// Load all measurement types to (a) map keys -> IDs and (b) read target units for conversion.
val types = measurementFacade.getAllMeasurementTypes().firstOrNull()
?: run {
LogManager.e(TAG, "Could not load MeasurementTypes from DB for $deviceName.")
_snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_measurement_types_not_loaded))
_snackbarEvents.tryEmit(
SnackbarEvent(
messageResId = R.string.bluetooth_connector_measurement_types_not_loaded
)
)
return@launch
}
fun getTypeIdFromMap(key: MeasurementTypeKey): Int? = typeKeyToIdMap[key]
val typeKeyToIdMap = types.associate { it.key to it.id }
val typeKeyToUnitMap = types.associate { it.key to it.unit }
fun getTypeId(key: MeasurementTypeKey) = typeKeyToIdMap[key]
fun getTargetUnit(key: MeasurementTypeKey) = typeKeyToUnitMap[key] ?: UnitType.NONE
// Declare raw units provided by ScaleMeasurement for each key.
// Percent-based values will "convert" to themselves (converter returns unchanged value).
val rawUnitByKey: Map<MeasurementTypeKey, UnitType> = mapOf(
MeasurementTypeKey.WEIGHT to UnitType.KG,
MeasurementTypeKey.BODY_FAT to UnitType.PERCENT,
MeasurementTypeKey.WATER to UnitType.PERCENT,
MeasurementTypeKey.MUSCLE to UnitType.PERCENT,
MeasurementTypeKey.VISCERAL_FAT to UnitType.PERCENT,
MeasurementTypeKey.BONE to UnitType.KG,
MeasurementTypeKey.LBM to UnitType.KG
)
val values = mutableListOf<MeasurementValue>()
measurementData.weight.takeIf { it.isFinite() && it > 0.0f }?.let {
getTypeIdFromMap(MeasurementTypeKey.WEIGHT)?.let { typeId ->
/**
* Adds a converted float value for the given key if present & valid.
* - Reads the raw unit for the key (what the device/handler provided).
* - Looks up the target unit from MeasurementType.
* - Converts using existing ConverterUtils.convertFloatValueUnit.
*/
fun addConvertedIfValid(
value: Float?,
key: MeasurementTypeKey,
isValid: (Float) -> Boolean = { it.isFinite() && it > 0f }
) {
val v = value ?: return
if (!isValid(v)) return
val rawUnit = rawUnitByKey[key] ?: UnitType.NONE
val target = getTargetUnit(key)
val converted = com.health.openscale.core.utils.ConverterUtils.convertFloatValueUnit(
v, rawUnit, target
)
getTypeId(key)?.let { typeId ->
values.add(
MeasurementValue(
measurementId = 0,
typeId = typeId,
floatValue = it
floatValue = converted
)
)
}
}
measurementData.fat.takeIf { it.isFinite() && it > 0.0f }?.let {
getTypeIdFromMap(MeasurementTypeKey.BODY_FAT)?.let { typeId ->
values.add(
MeasurementValue(
measurementId = 0,
typeId = typeId,
floatValue = it
)
)
}
}
measurementData.water.takeIf { it.isFinite() && it > 0.0f }?.let {
getTypeIdFromMap(MeasurementTypeKey.WATER)?.let { typeId ->
values.add(
MeasurementValue(
measurementId = 0,
typeId = typeId,
floatValue = it
)
)
}
}
measurementData.muscle.takeIf { it.isFinite() && it > 0.0f }?.let {
getTypeIdFromMap(MeasurementTypeKey.MUSCLE)?.let { typeId ->
values.add(
MeasurementValue(
measurementId = 0,
typeId = typeId,
floatValue = it
)
)
}
}
measurementData.visceralFat.takeIf { it.isFinite() && it >= 0.0f }?.let {
getTypeIdFromMap(MeasurementTypeKey.VISCERAL_FAT)?.let { typeId ->
values.add(
MeasurementValue(
measurementId = 0,
typeId = typeId,
floatValue = it
)
)
}
}
measurementData.bone.takeIf { it.isFinite() && it > 0.0f }?.let {
getTypeIdFromMap(MeasurementTypeKey.BONE)?.let { typeId ->
values.add(
MeasurementValue(
measurementId = 0,
typeId = typeId,
floatValue = it
)
)
}
}
// Add other values here (BMI, BMR etc. if available from ScaleMeasurement)
// Collect all supported values from ScaleMeasurement, converting as needed.
addConvertedIfValid(measurementData.weight, MeasurementTypeKey.WEIGHT)
addConvertedIfValid(measurementData.fat, MeasurementTypeKey.BODY_FAT)
addConvertedIfValid(measurementData.water, MeasurementTypeKey.WATER)
addConvertedIfValid(measurementData.muscle, MeasurementTypeKey.MUSCLE)
addConvertedIfValid(measurementData.visceralFat, MeasurementTypeKey.VISCERAL_FAT) { it.isFinite() && it >= 0f }
addConvertedIfValid(measurementData.bone, MeasurementTypeKey.BONE)
addConvertedIfValid(measurementData.lbm, MeasurementTypeKey.LBM)
if (values.isEmpty()) {
LogManager.w(TAG, "No valid values from measurement of $deviceName to save.")
_snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_measurement_no_values, messageFormatArgs = listOf(deviceName)))
_snackbarEvents.tryEmit(
SnackbarEvent(
messageResId = R.string.bluetooth_connector_measurement_no_values,
messageFormatArgs = listOf(deviceName)
)
)
return@launch
}
try {
val measurementId = measurementFacade.saveMeasurement(newDbMeasurement, values)
LogManager.i(TAG, "Measurement from $deviceName for User $currentAppUserId saved (ID: $measurementId). Values: ${values.size}")
LogManager.i(
TAG,
"Measurement from $deviceName for User $currentAppUserId saved (ID: $measurementId). Values: ${values.size}"
)
pendingSavedCount.incrementAndGet()
lastSavedArgs = listOf(measurementData.weight, deviceName)
savedBurstSignal.tryEmit(Unit)
} catch (e: Exception) {
LogManager.e(TAG, "Error saving measurement from $deviceName.", e)
_snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_measurement_save_error, messageFormatArgs = listOf(deviceName)))
_snackbarEvents.tryEmit(
SnackbarEvent(
messageResId = R.string.bluetooth_connector_measurement_save_error,
messageFormatArgs = listOf(deviceName)
)
)
}
}
}
/**
* Disconnects from the currently connected device, if any.
* This method initiates the disconnection process and starts a timeout