diff --git a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/16.json b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/16.json new file mode 100644 index 000000000..5e2a8bc4b --- /dev/null +++ b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/16.json @@ -0,0 +1,417 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "394cbd60aafb83b8d4beeb00b58404ea", + "entities": [ + { + "tableName": "User", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `birthDate` INTEGER NOT NULL, `gender` TEXT NOT NULL, `heightCm` REAL NOT NULL, `activityLevel` TEXT NOT NULL, `useAssistedWeighing` INTEGER NOT NULL, `amputations` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "birthDate", + "columnName": "birthDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gender", + "columnName": "gender", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "heightCm", + "columnName": "heightCm", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "activityLevel", + "columnName": "activityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "useAssistedWeighing", + "columnName": "useAssistedWeighing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amputations", + "columnName": "amputations", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "user_goals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `measurementTypeId` INTEGER NOT NULL, `goalValue` REAL NOT NULL, `goalTargetDate` INTEGER, PRIMARY KEY(`userId`, `measurementTypeId`), FOREIGN KEY(`userId`) REFERENCES `User`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`measurementTypeId`) REFERENCES `MeasurementType`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "measurementTypeId", + "columnName": "measurementTypeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "goalValue", + "columnName": "goalValue", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "goalTargetDate", + "columnName": "goalTargetDate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "measurementTypeId" + ] + }, + "indices": [ + { + "name": "index_user_goals_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_goals_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_user_goals_measurementTypeId", + "unique": false, + "columnNames": [ + "measurementTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_goals_measurementTypeId` ON `${TABLE_NAME}` (`measurementTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "User", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MeasurementType", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "measurementTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Measurement", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `User`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Measurement_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Measurement_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_Measurement_userId_timestamp", + "unique": true, + "columnNames": [ + "userId", + "timestamp" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Measurement_userId_timestamp` ON `${TABLE_NAME}` (`userId`, `timestamp`)" + } + ], + "foreignKeys": [ + { + "table": "User", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MeasurementValue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `measurementId` INTEGER NOT NULL, `typeId` INTEGER NOT NULL, `floatValue` REAL, `intValue` INTEGER, `textValue` TEXT, `dateValue` INTEGER, FOREIGN KEY(`measurementId`) REFERENCES `Measurement`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`typeId`) REFERENCES `MeasurementType`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "measurementId", + "columnName": "measurementId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "typeId", + "columnName": "typeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "floatValue", + "columnName": "floatValue", + "affinity": "REAL" + }, + { + "fieldPath": "intValue", + "columnName": "intValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "textValue", + "columnName": "textValue", + "affinity": "TEXT" + }, + { + "fieldPath": "dateValue", + "columnName": "dateValue", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MeasurementValue_measurementId", + "unique": false, + "columnNames": [ + "measurementId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MeasurementValue_measurementId` ON `${TABLE_NAME}` (`measurementId`)" + }, + { + "name": "index_MeasurementValue_typeId", + "unique": false, + "columnNames": [ + "typeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MeasurementValue_typeId` ON `${TABLE_NAME}` (`typeId`)" + } + ], + "foreignKeys": [ + { + "table": "Measurement", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "measurementId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MeasurementType", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "typeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MeasurementType", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `key` TEXT NOT NULL, `name` TEXT, `color` INTEGER NOT NULL, `icon` TEXT NOT NULL, `unit` TEXT NOT NULL, `inputType` TEXT NOT NULL, `displayOrder` INTEGER NOT NULL, `isDerived` INTEGER NOT NULL, `isEnabled` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `isOnRightYAxis` INTEGER NOT NULL, `isInternal` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unit", + "columnName": "unit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inputType", + "columnName": "inputType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayOrder", + "columnName": "displayOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDerived", + "columnName": "isDerived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isOnRightYAxis", + "columnName": "isOnRightYAxis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInternal", + "columnName": "isInternal", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MeasurementType_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MeasurementType_key` ON `${TABLE_NAME}` (`key`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '394cbd60aafb83b8d4beeb00b58404ea')" + ] + } +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleCommunicator.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleCommunicator.kt index 4ee5951e4..4b7999b16 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleCommunicator.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleCommunicator.kt @@ -104,6 +104,14 @@ interface ScaleCommunicator { /** Stream of [BluetoothEvent] emitted by the communicator. */ fun getEventsFlow(): SharedFlow + /** + * Recompute body composition on [raw] for the app user [userId], delegating + * to the active device handler. Used by the save pipeline to (re-)derive + * BIA outputs for the FINAL assigned user before persisting. Default no-op + * returns [raw] unchanged. + */ + fun recomputeBodyCompositionForUser(raw: ScaleMeasurement, userId: Int): ScaleMeasurement = raw + /** Deliver feedback for a previously requested user interaction. */ suspend fun processUserInteractionFeedback( interactionType: BluetoothEvent.UserInteractionType, diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyComposition.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyComposition.kt new file mode 100644 index 000000000..775367579 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyComposition.kt @@ -0,0 +1,101 @@ +/* + * openScale + * Copyright (C) 2026 Dany Mestas + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs + +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.data.GenderType + +/** + * Shared body-composition derivations, factored out of the individual scale + * handlers so a given calculator lib is bound to (user, raw inputs) in exactly + * one place. Handlers call these from both their parse path and their + * [com.health.openscale.core.bluetooth.scales.ScaleDeviceHandler.recomputeBodyComposition] + * override, which is what lets body composition be (re)computed for the FINAL + * assigned user instead of whoever was merely selected at weigh-in. + * + * Each function reads the raw, user-independent quantities already present on + * [ScaleMeasurement] (weight + impedance/resistance) and mutates the derived + * fields in place, returning the same instance. A guard failure (no weight / + * no impedance) returns [raw] untouched. + */ +object BodyComposition { + + /** + * MiScaleLib-based scales (Xiaomi Mi Body Composition, Eufy C20). [raw.impedance] + * holds impedance in Ω. Sets fat/water/muscle/bone/lbm/visceralFat. + */ + fun fromMiScale(raw: ScaleMeasurement, user: ScaleUser): ScaleMeasurement { + val imp = raw.impedance.toFloat() + if (raw.weight <= 0f || imp <= 0f) return raw + val sex = if (user.gender == GenderType.MALE) 1 else 0 + val lib = MiScaleLib(sex, user.age, user.bodyHeight) + return raw.apply { + fat = lib.getBodyFat(weight, imp) + water = lib.getWater(weight, imp) + muscle = lib.getMuscle(weight, imp) + bone = lib.getBoneMass(weight, imp) + lbm = lib.getLBM(weight, imp) + visceralFat = lib.getVisceralFat(weight) + } + } + + /** + * TrisaBodyAnalyzeLib-based scales (Trisa, QN, ES-CS20M). [raw.impedance] + * holds the RAW resistance; the QN resistance→impedance conversion is applied + * before the lib (shared lineage). Sets fat/water/muscle/bone — callers that + * also derive lbm do so from the resulting fat. + */ + fun fromTrisa(raw: ScaleMeasurement, user: ScaleUser): ScaleMeasurement { + val r = raw.impedance.toFloat() + if (raw.weight <= 0f || r <= 0f) return raw + val imp = if (r < 410f) 3.0f else 0.3f * (r - 400f) + val sex = if (user.gender == GenderType.MALE) 1 else 0 + val lib = TrisaBodyAnalyzeLib(sex, user.age, user.bodyHeight) + return raw.apply { + fat = lib.getFat(weight, imp) + water = lib.getWater(weight, imp) + muscle = lib.getMuscle(weight, imp) + bone = lib.getBone(weight, imp) + } + } + + /** + * Builds a [StandardImpedanceLib] configured from [raw] + [user] (height in + * metres, impedance in Ω), or null when inputs are unusable. Field mapping is + * left to the caller because the StandardImpedanceLib scales (Etekcity, + * Dr Trust, Cult) intentionally map/coerce its outputs differently. + * + * @param maxImpedance exclusive upper bound; impedance ≥ this returns null. + */ + fun standardImpedanceLib( + raw: ScaleMeasurement, + user: ScaleUser, + maxImpedance: Double = Double.MAX_VALUE, + ): StandardImpedanceLib? { + val impedance = raw.impedance + if (raw.weight <= 0f || impedance <= 0.0 || impedance >= maxImpedance) return null + return StandardImpedanceLib( + gender = user.gender, + age = user.age, + weightKg = raw.weight.toDouble(), + heightM = user.bodyHeight / 100.0, + impedance = impedance, + ) + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/CultSmartScaleProHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/CultSmartScaleProHandler.kt index cfb94f98f..985d68bbe 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/CultSmartScaleProHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/CultSmartScaleProHandler.kt @@ -20,6 +20,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.BodyComposition import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date @@ -262,33 +263,31 @@ class CultSmartScaleProHandler : ScaleDeviceHandler() { } if (withBodyComp && user != null && pendingImpedance > 0.0) { - val lib = StandardImpedanceLib( - gender = user.gender, - age = user.age, - weightKg = pendingWeight.toDouble(), - heightM = user.bodyHeight / 100.0, - impedance = pendingImpedance - ) - - m.fat = lib.totalFatPercentage.toFloat().coerceIn(0f, 75f) - m.water = lib.totalBodyWaterPercentage.toFloat().coerceIn(0f, 80f) - m.muscle = lib.skeletalMuscleMassKg.toFloat().coerceIn(0f, 100f) - m.bone = lib.boneMassKg.toFloat().coerceIn(0f, 10f) - m.lbm = lib.fatFreeMassKg.toFloat().coerceIn(0f, 150f) - m.bmr = lib.basalMetabolicRate.toFloat().coerceIn(0f, 5000f) - m.impedance = pendingImpedance - - logI( - "body comp (StandardImpedanceLib, impedance=${pendingImpedance}Ω): " + - "fat=${m.fat}% water=${m.water}% muscle=${m.muscle}kg " + - "bone=${m.bone}kg lbm=${m.lbm}kg bmr=${m.bmr}kcal" - ) + m.impedance = pendingImpedance + recomputeBodyComposition(m, user) } logI("publishing → weight=${m.weight}kg fat=${m.fat}% water=${m.water}% muscle=${m.muscle}kg bmr=${m.bmr}kcal impedance=${m.impedance}Ω") publish(m) } + /** + * Re-derive body composition for [user] from the raw weight + impedance on + * [raw] (StandardImpedanceLib). Shared by the parse path and the + * save-pipeline recompute so derived values match the FINAL assigned user. + */ + override fun recomputeBodyComposition(raw: ScaleMeasurement, user: ScaleUser): ScaleMeasurement { + val lib = BodyComposition.standardImpedanceLib(raw, user) ?: return raw + return raw.apply { + fat = lib.totalFatPercentage.toFloat().coerceIn(0f, 75f) + water = lib.totalBodyWaterPercentage.toFloat().coerceIn(0f, 80f) + muscle = lib.skeletalMuscleMassKg.toFloat().coerceIn(0f, 100f) + bone = lib.boneMassKg.toFloat().coerceIn(0f, 10f) + lbm = lib.fatFreeMassKg.toFloat().coerceIn(0f, 150f) + bmr = lib.basalMetabolicRate.toFloat().coerceIn(0f, 5000f) + } + } + private fun resetPendingState() { pendingWeight = 0f pendingImpedance = 0.0 diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DrTrustSSW532Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DrTrustSSW532Handler.kt index 2bc6a664d..6aba98068 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DrTrustSSW532Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DrTrustSSW532Handler.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.libs.BodyComposition import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib import com.health.openscale.core.data.GenderType @@ -305,6 +306,26 @@ class DrTrustSSW532Handler : ScaleDeviceHandler() { requestDisconnect() } + /** + * Re-derive body composition for [user] from the raw whole-body impedance on + * [raw] (StandardImpedanceLib). Invoked by the save pipeline so derived + * values match the FINAL assigned user. VISCERAL_FAT is intentionally left + * as-is: it needs the segmental z3 reading which is not persisted on the row. + */ + override fun recomputeBodyComposition(raw: ScaleMeasurement, user: ScaleUser): ScaleMeasurement { + val lib = BodyComposition.standardImpedanceLib(raw, user) ?: return raw + val fatPct = lib.totalFatPercentage.toFloat() + if (fatPct <= 0f) return raw // out of calibration range — keep existing values + return raw.apply { + fat = fatPct.coerceIn(0f, 75f) + water = lib.totalBodyWaterPercentage.toFloat().coerceIn(0f, 80f) + muscle = lib.skeletalMusclePercentage.toFloat().coerceIn(0f, 99f) + bone = lib.boneMassKg.toFloat().coerceIn(0f, 10f) + bmr = lib.basalMetabolicRate.toFloat().coerceIn(0f, 5000f) + lbm = lib.fatFreeMassKg.toFloat().coerceIn(0f, 150f) + } + } + private fun reset() { pendingWeightKg = 0f pkt0Valid = false diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ESCS20MHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ESCS20MHandler.kt index 9815cee85..1b1f9a239 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ESCS20MHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ESCS20MHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.libs.BodyComposition import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.TrisaBodyAnalyzeLib import com.health.openscale.core.bluetooth.libs.YunmaiLib @@ -486,6 +487,21 @@ class ESCS20MHandler : ScaleDeviceHandler() { impedance = m.impedance } + /** + * Re-derive body composition for [user] from the raw weight + BIA resistance + * on [raw] (TrisaBodyAnalyzeLib; QN resistance→impedance conversion applied + * first, mirroring the parse path). [raw.impedance] holds the raw resistance. + * Invoked by the save pipeline so values match the FINAL assigned user. + */ + override fun recomputeBodyComposition(raw: ScaleMeasurement, user: ScaleUser): ScaleMeasurement { + // Shared Trisa derivation (fat/water/muscle/bone) + this scale's extra LBM. + val result = BodyComposition.fromTrisa(raw, user) + if (raw.weight > 0f && raw.impedance > 0.0) { + result.lbm = raw.weight * (100f - result.fat) / 100f + } + return result + } + // ── Helpers ─────────────────────────────────────────────────────────────── private fun u16be(b: ByteArray, off: Int): Int { diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EtekcityESF551Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EtekcityESF551Handler.kt index 164e6fc46..85ec5dc5c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EtekcityESF551Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EtekcityESF551Handler.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.libs.BodyComposition import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib import com.health.openscale.core.data.WeightUnit @@ -123,18 +124,7 @@ class EtekcityESF551Handler : ScaleDeviceHandler() { ) if (impedance > 0 && impedance < 1500) { - val lib = StandardImpedanceLib( - gender = user.gender, - age = user.age, - weightKg = weightKg, - heightM = user.bodyHeight / 100.0, - impedance = impedance, - ) - measurement.fat = lib.totalFatPercentage.toFloat() - measurement.water = lib.totalBodyWaterPercentage.toFloat() - measurement.muscle = lib.skeletalMusclePercentage.toFloat() - measurement.bone = lib.boneMassKg.toFloat() - measurement.bmr = lib.basalMetabolicRate.toFloat() + recomputeBodyComposition(measurement, user) } if (data[20] == 1.toByte() && impedance > 0) { @@ -145,6 +135,22 @@ class EtekcityESF551Handler : ScaleDeviceHandler() { return null } + /** + * Re-derive body composition for [user] from the raw weight + impedance on + * [raw] (StandardImpedanceLib). Shared by the parse path and the + * save-pipeline recompute so derived values match the FINAL assigned user. + */ + override fun recomputeBodyComposition(raw: ScaleMeasurement, user: ScaleUser): ScaleMeasurement { + val lib = BodyComposition.standardImpedanceLib(raw, user, maxImpedance = 1500.0) ?: return raw + return raw.apply { + fat = lib.totalFatPercentage.toFloat() + water = lib.totalBodyWaterPercentage.toFloat() + muscle = lib.skeletalMusclePercentage.toFloat() + bone = lib.boneMassKg.toFloat() + bmr = lib.basalMetabolicRate.toFloat() + } + } + private fun setUnit(unit: WeightUnit) { val unitByte = unit.toInt().toByte() val cmd = byteArrayOf( diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EufyC20Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EufyC20Handler.kt index 4aacba9ec..8ffbf99f1 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EufyC20Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EufyC20Handler.kt @@ -5,6 +5,7 @@ import android.util.SparseArray import androidx.core.util.isEmpty import androidx.core.util.size import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.libs.BodyComposition import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.MiScaleLib import com.health.openscale.core.data.GenderType @@ -104,15 +105,7 @@ class EufyC20Handler : ScaleDeviceHandler() { // compute composition if possible if (s.hasWeight() && s.impedance > 0.0) { - val sex = if (user.gender == GenderType.MALE) 1 else 0 - val lib = MiScaleLib(sex, user.age, user.bodyHeight) - val wf = s.weight - s.fat = lib.getBodyFat(wf, s.impedance.toFloat()) - s.water = lib.getWater(wf, s.impedance.toFloat()) - s.muscle = lib.getMuscle(wf, s.impedance.toFloat()) - s.bone = lib.getBoneMass(wf, s.impedance.toFloat()) - s.lbm = lib.getLBM(wf, s.impedance.toFloat()) - s.visceralFat = lib.getVisceralFat(wf) + recomputeBodyComposition(s, user) } // if full measurement -> clear stored and stop @@ -134,4 +127,7 @@ class EufyC20Handler : ScaleDeviceHandler() { override fun onConnected(user: ScaleUser) { logI("Eufy C20 handler - onConnected (broadcast-only handler)") } + + override fun recomputeBodyComposition(raw: ScaleMeasurement, user: ScaleUser): ScaleMeasurement = + BodyComposition.fromMiScale(raw, user) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt index b73d103a8..8301e51b8 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt @@ -268,28 +268,45 @@ class HuaweiCH100SHandler : ScaleDeviceHandler() { this.fat = fat if (impedance in 1..3999) { this.impedance = impedance.toDouble() - // Water%, muscle%, bone, BMR, visceral fat are not sent by the scale. - // Compute app-side from impedance using BIA formulas (Chipsea chipset). - val user = currentAppUser() - val lib = EtekcityLib( - gender = user.gender, - age = user.age, - weightKg = weight.toDouble(), - heightM = user.bodyHeight.toDouble() / 100.0, - impedance = impedance.toDouble() - ) - this.water = lib.water.toFloat() - this.muscle = lib.skeletalMusclePercentage.toFloat() - this.bone = lib.boneMass.toFloat() - this.bmr = lib.basalMetabolicRate.toFloat() - this.visceralFat = lib.visceralFat.toFloat() } } + // Water%, muscle%, bone, BMR, visceral fat are not sent by the scale — + // derived app-side from impedance (Chipsea chipset). fat IS sent by the + // scale and is user-independent, so it is left untouched here. + if (m.impedance > 0.0) { + recomputeBodyComposition(m, currentAppUser()) + } publish(m) logI("Measurement: $weight kg, fat=$fat%, imp=$impedance Ω, user=$userId @ ${ts(dt)}") sendPlain(CMD_FAT_ACK, byteArrayOf(0x00)) } + /** + * Re-derive the app-side body-composition fields (water/muscle/bone/BMR/ + * visceral fat) for [user] from the raw impedance on [raw] (EtekcityLib). + * BODY_FAT is sent by the scale (user-independent) and left untouched. + * Shared by the parse path and the save-pipeline recompute so values match + * the FINAL assigned user. + */ + override fun recomputeBodyComposition(raw: ScaleMeasurement, user: ScaleUser): ScaleMeasurement { + val impedance = raw.impedance + if (raw.weight <= 0f || impedance < 1.0 || impedance > 3999.0) return raw + val lib = EtekcityLib( + gender = user.gender, + age = user.age, + weightKg = raw.weight.toDouble(), + heightM = user.bodyHeight.toDouble() / 100.0, + impedance = impedance + ) + return raw.apply { + water = lib.water.toFloat() + muscle = lib.skeletalMusclePercentage.toFloat() + bone = lib.boneMass.toFloat() + bmr = lib.basalMetabolicRate.toFloat() + visceralFat = lib.visceralFat.toFloat() + } + } + // --- Commands ------------------------------------------------------------- private fun sendSetTime() { diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt index bba807cc7..45ff45683 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.libs.BodyComposition import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.MiScaleLib import com.health.openscale.core.data.GenderType @@ -350,14 +351,7 @@ class MiScaleHandler : ScaleDeviceHandler() { if (imp > 0) { // Store the raw impedance so body composition can be recomputed later. m.impedance = imp.toDouble() - val sex = if (user.gender == GenderType.MALE) 1 else 0 - val lib = MiScaleLib(sex, user.age, user.bodyHeight) - m.water = lib.getWater(m.weight, imp.toFloat()) - m.visceralFat = lib.getVisceralFat(m.weight) - m.fat = lib.getBodyFat(m.weight, imp.toFloat()) - m.muscle = lib.getMuscle(m.weight, imp.toFloat()) - m.lbm = lib.getLBM(m.weight, imp.toFloat()) - m.bone = lib.getBoneMass(m.weight, imp.toFloat()) + recomputeBodyComposition(m, user) } } @@ -367,6 +361,15 @@ class MiScaleHandler : ScaleDeviceHandler() { return true } + /** + * Re-derive body composition for [user] from the raw weight + impedance on + * [raw]. Shared by the parse path and the save-pipeline recompute so derived + * values always match the FINAL assigned user (see + * [com.health.openscale.core.bluetooth.scales.ScaleDeviceHandler.recomputeBodyComposition]). + */ + override fun recomputeBodyComposition(raw: ScaleMeasurement, user: ScaleUser): ScaleMeasurement = + BodyComposition.fromMiScale(raw, user) + /** History record (10 bytes): [status][weightLE(2)][yearLE(2)][mon][day][h][m][s] */ private fun parseHistory10(d: ByteArray, user: ScaleUser): Boolean { if (d.size != 10) return false diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleS400Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleS400Handler.kt index 780bbca2a..ffcbb97db 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleS400Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleS400Handler.kt @@ -330,31 +330,49 @@ class MiScaleS400Handler : ScaleDeviceHandler() { "hr=${finalized.heartRate}, timedOut=${finalized.timedOut}" ) - val boneFormula = readBoneFormula() - val bmrFormula = readBmrFormula() + // Carry only the raw, user-independent quantities here; body composition + // is derived in recomputeBodyComposition() so it always uses the FINAL + // assigned user (see ScaleDeviceHandler.recomputeBodyComposition). + val scaleMeasurement = ScaleMeasurement().apply { + dateTime = Date() + weight = finalized.weightKg + userId = user.id + heartRate = finalized.heartRate ?: 0 + impedance = finalized.impedanceHigh.toDouble() + finalized.impedanceLow?.let { impedanceLow = it.toDouble() } + } + recomputeBodyComposition(scaleMeasurement, user) + + publish(scaleMeasurement) + + return BroadcastAction.CONSUMED_STOP + } + + /** + * Re-derive S400 body composition for [user] from the raw weight + impedance + * bands already on [raw]. Shared by [onAdvertisement] (initial parse) and the + * save pipeline (final-user recompute), so the two can never diverge. + */ + override fun recomputeBodyComposition(raw: ScaleMeasurement, user: ScaleUser): ScaleMeasurement { + val rHigh = raw.impedance.toFloat() + if (raw.weight <= 0f || rHigh <= 0f) return raw + // Single-band fallback when Packet B never arrived (older firmware path). + val rLow = if (raw.impedanceLow > 0.0) raw.impedanceLow.toFloat() else rHigh val composition = S400BodyComposition.compute( S400Inputs( age = user.age, sexMale = user.gender == GenderType.MALE, heightCm = user.bodyHeight, - weightKg = finalized.weightKg, - rHighRaw = finalized.impedanceHigh, - // Single-band fallback when Packet B never arrives (older firmware path). - rLowRaw = finalized.impedanceLow ?: finalized.impedanceHigh, + weightKg = raw.weight, + rHighRaw = rHigh, + rLowRaw = rLow, ), - boneFormula = boneFormula, - bmrFormula = bmrFormula, + boneFormula = readBoneFormula(), + bmrFormula = readBmrFormula(), ) - val scaleMeasurement = ScaleMeasurement().apply { - dateTime = Date() - weight = finalized.weightKg - userId = user.id - heartRate = finalized.heartRate ?: 0 - impedance = finalized.impedanceHigh.toDouble() - finalized.impedanceLow?.let { impedanceLow = it.toDouble() } - + return raw.apply { fat = composition.bfPct ?: 0f water = composition.tbwPct ?: 0f muscle = composition.smmPct ?: 0f @@ -367,10 +385,6 @@ class MiScaleS400Handler : ScaleDeviceHandler() { protein = composition.proteinPct ?: 0f bcm = composition.bcmKg ?: 0f } - - publish(scaleMeasurement) - - return BroadcastAction.CONSUMED_STOP } private fun readBoneFormula(): BoneFormula = diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt index 91dd57d73..fb5171b37 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt @@ -449,6 +449,13 @@ abstract class ModernScaleAdapter( override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = lastSnapshot[userId] } + override fun recomputeBodyCompositionForUser(raw: ScaleMeasurement, userId: Int): ScaleMeasurement { + // usersSnapshot holds every app user already mapped to ScaleUser; fall + // back to the selected user, then to the raw value if neither resolves. + val user = usersSnapshot.firstOrNull { it.id == userId } ?: selectedUserSnapshot ?: return raw + return handler.recomputeBodyComposition(raw, user) + } + protected fun cleanup() { _isConnected.value = false _isConnecting.value = false diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneHandler.kt index b918a46c3..e923072be 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneHandler.kt @@ -207,10 +207,6 @@ class OneByoneHandler : ScaleDeviceHandler() { if (nowMs - lastSavedAt < DATE_TIME_THRESHOLD_MS) return lastSavedAt = nowMs - // Build composition using OneByoneLib (same as legacy) - val (sex, peopleType) = mapUserToLibParams(user) - val lib = OneByoneLib(sex, user.age, user.bodyHeight, peopleType) - val m = ScaleMeasurement().apply { userId = user.id dateTime = if (hasTimestamp) whenCal.time else Calendar.getInstance().time @@ -220,15 +216,7 @@ class OneByoneHandler : ScaleDeviceHandler() { } try { - // Derivations - val fatPct = lib.getBodyFat(m.weight, impedanceOhm) - m.fat = fatPct - m.water = lib.getWater(fatPct) - m.bone = lib.getBoneMass(m.weight, impedanceOhm) - m.visceralFat = lib.getVisceralFat(m.weight) - m.muscle = lib.getMuscle(m.weight, impedanceOhm) - m.lbm = lib.getLBM(m.weight, m.fat) - + recomputeBodyComposition(m, user) publish(m) } catch (t: Throwable) { // If library throws on impossible inputs, just log & ignore this frame @@ -236,6 +224,27 @@ class OneByoneHandler : ScaleDeviceHandler() { } } + /** + * Re-derive body composition for [user] from the raw weight + impedance on + * [raw] (OneByoneLib). Shared by the parse path and the save-pipeline + * recompute so derived values always match the FINAL assigned user. + */ + override fun recomputeBodyComposition(raw: ScaleMeasurement, user: ScaleUser): ScaleMeasurement { + val impedanceOhm = raw.impedance.toFloat() + if (raw.weight <= 0f || impedanceOhm <= 0f) return raw + val (sex, peopleType) = mapUserToLibParams(user) + val lib = OneByoneLib(sex, user.age, user.bodyHeight, peopleType) + return raw.apply { + val fatPct = lib.getBodyFat(weight, impedanceOhm) + fat = fatPct + water = lib.getWater(fatPct) + bone = lib.getBoneMass(weight, impedanceOhm) + visceralFat = lib.getVisceralFat(weight) + muscle = lib.getMuscle(weight, impedanceOhm) + lbm = lib.getLBM(weight, fatPct) + } + } + // --- Command builders ------------------------------------------------------ /** `FD 37 00..00 XX`, where XX is XOR of all previous bytes. */ diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneNewHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneNewHandler.kt index 2d544f93f..93e635841 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneNewHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneNewHandler.kt @@ -189,6 +189,17 @@ class OneByoneNewHandler : ScaleDeviceHandler() { m.lbm = lib.getLBM(m.weight, impedanceOhm) } + /** + * Re-derive body composition for [user] from the raw weight + impedance on + * [raw], reusing [populateBodyComp]. Invoked by the save pipeline so derived + * values always match the FINAL assigned user. + */ + override fun recomputeBodyComposition(raw: ScaleMeasurement, user: ScaleUser): ScaleMeasurement { + if (raw.weight <= 0f || raw.impedance <= 0.0) return raw + populateBodyComp(raw, raw.impedance.toInt(), user) + return raw + } + // ---- Outbound commands ---------------------------------------------------- /** Initial kick message (D7 checksum), includes current timestamp, unit, and one entry for the current user. */ diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/QNHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/QNHandler.kt index 5494a58e1..fe4b9d527 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/QNHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/QNHandler.kt @@ -21,6 +21,7 @@ import android.os.Handler import android.os.Looper import com.health.openscale.R import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.libs.BodyComposition import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.TrisaBodyAnalyzeLib import com.health.openscale.core.data.WeightUnit @@ -531,23 +532,21 @@ class QNHandler : ScaleDeviceHandler() { impedance = r1.toDouble() } - val impedance = if (r1 < 410f) 3.0f else 0.3f * (r1 - 400f) + recomputeBodyComposition(m, user) - val trisa = TrisaBodyAnalyzeLib( - if (user.gender.isMale()) 1 else 0, - user.age, - user.bodyHeight - ) - - m.fat = trisa.getFat(weightKg, impedance) - m.water = trisa.getWater(weightKg, impedance) - m.muscle = trisa.getMuscle(weightKg, impedance) - m.bone = trisa.getBone(weightKg, impedance) - - logD("QN: publishing $source measurement weight=$weightKg kg r1=$r1 impedance=$impedance") + logD("QN: publishing $source measurement weight=$weightKg kg r1=$r1") publish(snapshot(m)) } + /** + * Re-derive body composition for [user] from the raw weight + resistance on + * [raw] (TrisaBodyAnalyzeLib). [raw.impedance] holds the raw resistance; the + * lib is fed the converted impedance. Shared by the parse path and the + * save-pipeline recompute so derived values match the FINAL assigned user. + */ + override fun recomputeBodyComposition(raw: ScaleMeasurement, user: ScaleUser): ScaleMeasurement = + BodyComposition.fromTrisa(raw, user) + /** Make a defensive snapshot so later mutations don’t affect published data. */ private fun snapshot(m: ScaleMeasurement) = ScaleMeasurement().also { it.userId = m.userId diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RealmeSmartScaleHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RealmeSmartScaleHandler.kt index 2fd79d67e..7748acbcf 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RealmeSmartScaleHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RealmeSmartScaleHandler.kt @@ -157,6 +157,29 @@ class RealmeSmartScaleHandler : ScaleDeviceHandler() { publish(measurement) } + /** + * Re-derive body composition for [user] from the raw weight + impedance on + * [raw] (YunmaiLib). Invoked by the save pipeline so values match the FINAL + * assigned user. + */ + override fun recomputeBodyComposition(raw: ScaleMeasurement, user: ScaleUser): ScaleMeasurement { + val impedance = raw.impedance.toInt() + if (raw.weight <= 0f || impedance <= 0) return raw + val sex = if (user.gender.isMale()) 1 else 0 + val calc = com.health.openscale.core.bluetooth.libs.YunmaiLib(sex, user.bodyHeight, user.activityLevel) + val w = raw.weight + val fatPct = calc.getFat(user.age, w, impedance) + if (fatPct <= 0f) return raw + return raw.apply { + fat = fatPct + muscle = calc.getMuscle(fatPct) / w * 100.0f + water = calc.getWater(fatPct) + bone = calc.getBoneMass(muscle, w) + lbm = calc.getLeanBodyMass(w, fatPct) + visceralFat = calc.getVisceralFat(fatPct, user.age) + } + } + // --- PROTOCOL DYNAMIC GENERATORS --- private fun deobfuscate(data: ByteArray): ByteArray { diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt index 7c8c755bf..a41cbea1d 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt @@ -216,6 +216,18 @@ abstract class ScaleDeviceHandler { */ open fun onAdvertisement(result: ScanResult, user: ScaleUser): BroadcastAction = BroadcastAction.IGNORED + /** + * Recompute body composition for [user] from the raw quantities already on + * [raw] (weight, impedance bands, …). Invoked by the save pipeline AFTER the + * final user is resolved (selected-user snapshot + smart weight-assignment), + * so BIA outputs (fat/water/muscle/…) never belong to a different profile + * than the row they are stored under. + * + * BIA handlers override this to re-run their composition lib with [user]; + * the default is a no-op for handlers that emit weight only. + */ + open fun recomputeBodyComposition(raw: ScaleMeasurement, user: ScaleUser): ScaleMeasurement = raw + // --- Protected helper methods (use these from your handler) ---------------- /** Enable notifications for a characteristic. */ diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/TrisaBodyAnalyzeHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/TrisaBodyAnalyzeHandler.kt index afe19dfb1..151859009 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/TrisaBodyAnalyzeHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/TrisaBodyAnalyzeHandler.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.libs.BodyComposition import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.TrisaBodyAnalyzeLib import com.health.openscale.core.service.ScannedDeviceInfo @@ -199,19 +200,22 @@ class TrisaBodyAnalyzeHandler : ScaleDeviceHandler() { val r2Offset = 9 + if (hasR1) 4 else 0 if (hasR2 && r2Offset + 4 <= data.size && isValidUser(user)) { val resistance2 = getBase10Float(data, r2Offset) - val impedance = if (resistance2 < 410f) 3.0f else 0.3f * (resistance2 - 400f) - val sexFlag = if (user!!.gender.isMale()) 1 else 0 - val lib = TrisaBodyAnalyzeLib(sexFlag, user.age, user.bodyHeight) - measurement.fat = lib.getFat(weightKg, impedance) - measurement.water = lib.getWater(weightKg, impedance) - measurement.muscle = lib.getMuscle(weightKg, impedance) - measurement.bone = lib.getBone(weightKg, impedance) // Store the raw resistance so body composition can be recomputed later. measurement.impedance = resistance2.toDouble() + recomputeBodyComposition(measurement, user!!) } return measurement } + /** + * Re-derive body composition for [user] from the raw weight + resistance on + * [raw] (TrisaBodyAnalyzeLib). [raw.impedance] holds the raw resistance; the + * lib is fed the converted impedance. Shared by the parse path and the + * save-pipeline recompute so derived values match the FINAL assigned user. + */ + override fun recomputeBodyComposition(raw: ScaleMeasurement, user: ScaleUser): ScaleMeasurement = + BodyComposition.fromTrisa(raw, user) + // --- Command helpers (host → device) ------------------------------------- private fun writeCommand(opcode: Byte, arg: Int) { diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt b/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt index 869e98a9b..72c1c812d 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt @@ -24,6 +24,10 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import com.health.openscale.core.bluetooth.libs.S400BodyComposition +import com.health.openscale.core.bluetooth.libs.S400Inputs +import com.health.openscale.core.bluetooth.libs.Reliability +import com.health.openscale.core.data.ActivityLevel import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.MeasurementTypeKey @@ -47,7 +51,7 @@ object DatabaseModule { @Singleton fun provideDatabase(@ApplicationContext ctx: Context): AppDatabase = Room.databaseBuilder(ctx, AppDatabase::class.java, AppDatabase.Companion.DATABASE_NAME) - .addMigrations(MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15) + .addMigrations(MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16) .build() @Provides @@ -74,7 +78,7 @@ object DatabaseModule { MeasurementValue::class, MeasurementType::class, ], - version = 15, + version = 16, exportSchema = true ) @TypeConverters(DatabaseConverters::class) @@ -451,3 +455,133 @@ val MIGRATION_14_15 = object : Migration(14, 15) { } } } + +/** + * Data-only repair. No schema change. + * + * Xiaomi S400 (and any dual-band BIA) measurements stored body-composition that + * was computed with whichever user profile was *selected* at weigh-in, even + * though the row is attributed to a different user (smart-assignment reassigns + * by closest weight). Sex/height/age differences inflated body fat dramatically + * (e.g. a male row carried a female-profile 30% instead of ~18%). + * + * The raw inputs (weight + both impedance bands) are persisted, so the fields + * are fully reconstructable. This re-derives them with each row's OWN (stored) + * user via the same pure pipeline the scale uses ([S400BodyComposition.compute], + * S400 defaults: MI_LEGACY bone, Cunningham-1991 BMR, foot-to-foot 1.10). + * Rows whose inputs fall outside the validated range (compute returns + * NOT_AVAILABLE) are left untouched. + */ +val MIGRATION_15_16 = object : Migration(15, 16) { + override fun migrate(db: SupportSQLiteDatabase) { + // Activity factors mirror DerivedValuesCalculator.processTDEECalculation. + fun activityFactor(level: String): Float = when (level) { + ActivityLevel.SEDENTARY.name -> 1.2f + ActivityLevel.MILD.name -> 1.375f + ActivityLevel.MODERATE.name -> 1.55f + ActivityLevel.HEAVY.name -> 1.725f + ActivityLevel.EXTREME.name -> 1.9f + else -> 1.2f + } + + data class Row( + val measurementId: Int, + val timestamp: Long, + val gender: String, + val heightCm: Float, + val birthDate: Long, + val activityLevel: String, + val weightKg: Float, + val impHigh: Float, + val impLow: Float, + ) + + // Only dual-band (S400) rows match: inner joins require WEIGHT + both + // impedance bands, and a valid user. typeIds resolved by key so the + // migration is independent of seeded id values. + val rows = mutableListOf() + db.query( + """ + SELECT m.id, m.timestamp, u.gender, u.heightCm, u.birthDate, u.activityLevel, + w.floatValue AS weight, + ih.floatValue AS impHigh, + il.floatValue AS impLow + FROM Measurement m + JOIN User u ON u.id = m.userId + JOIN MeasurementValue w ON w.measurementId = m.id AND w.typeId = (SELECT id FROM MeasurementType WHERE `key`='WEIGHT') + JOIN MeasurementValue ih ON ih.measurementId = m.id AND ih.typeId = (SELECT id FROM MeasurementType WHERE `key`='IMPEDANCE') + JOIN MeasurementValue il ON il.measurementId = m.id AND il.typeId = (SELECT id FROM MeasurementType WHERE `key`='IMPEDANCE_LOW') + WHERE w.floatValue IS NOT NULL AND ih.floatValue IS NOT NULL AND il.floatValue IS NOT NULL + """.trimIndent() + ).use { c -> + while (c.moveToNext()) { + rows += Row( + measurementId = c.getInt(0), + timestamp = c.getLong(1), + gender = c.getString(2), + heightCm = c.getFloat(3), + birthDate = c.getLong(4), + activityLevel = c.getString(5), + weightKg = c.getFloat(6), + impHigh = c.getFloat(7), + impLow = c.getFloat(8), + ) + } + } + + if (rows.isEmpty()) return + + // Updates a single derived MeasurementValue in place (no-op if absent). + fun update(measurementId: Int, key: String, value: Float) { + db.execSQL( + """ + UPDATE MeasurementValue + SET floatValue = ? + WHERE measurementId = ? + AND typeId = (SELECT id FROM MeasurementType WHERE `key` = ?) + """.trimIndent(), + arrayOf(value, measurementId, key) + ) + } + + db.beginTransaction() + try { + for (row in rows) { + val age = com.health.openscale.core.utils.CalculationUtils.ageOn(row.timestamp, row.birthDate) + val result = S400BodyComposition.compute( + S400Inputs( + age = age, + sexMale = row.gender == "MALE", + heightCm = row.heightCm, + weightKg = row.weightKg, + rHighRaw = row.impHigh, + rLowRaw = row.impLow, + ) + ) + + // Inputs outside the validated range → leave the row as-is. + if (result.reliability == Reliability.NOT_AVAILABLE || result.bfPct == null) continue + + fun round(v: Float) = com.health.openscale.core.utils.CalculationUtils.roundTo(v) + + update(row.measurementId, "BODY_FAT", round(result.bfPct)) + result.tbwPct?.let { update(row.measurementId, "WATER", round(it)) } + result.smmPct?.let { update(row.measurementId, "MUSCLE", round(it)) } + result.ffmKg?.let { update(row.measurementId, "LBM", round(it)) } + result.boneKg?.let { update(row.measurementId, "BONE", round(it)) } + result.vfi?.let { update(row.measurementId, "VISCERAL_FAT", round(it)) } + result.ecwPct?.let { update(row.measurementId, "ECW", round(it)) } + result.icwPct?.let { update(row.measurementId, "ICW", round(it)) } + result.proteinPct?.let { update(row.measurementId, "PROTEIN", round(it)) } + result.bcmKg?.let { update(row.measurementId, "BCM", round(it)) } + result.bmrKcal?.let { bmr -> + update(row.measurementId, "BMR", round(bmr)) + update(row.measurementId, "TDEE", round(bmr * activityFactor(row.activityLevel))) + } + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt index fd370c041..ba1088b4c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt @@ -223,9 +223,16 @@ class MeasurementFacade @Inject constructor( // BLE // ------------------------------------------------------------------------- + /** + * @param recomputeForUser optional hook invoked AFTER the final user is + * resolved by smart-assignment; given that user's id it returns the + * re-derived value list (e.g. BIA body-composition recomputed for the + * correct profile). When null, the original [values] are persisted as-is. + */ suspend fun saveMeasurementFromBleDevice( measurement: Measurement, values: List, + recomputeForUser: (suspend (userId: Int) -> List)? = null, ) { val ref = pendingReferenceUser if (ref != null) { @@ -233,7 +240,10 @@ class MeasurementFacade @Inject constructor( crud.saveMeasurement(measurement, finalValues) } else { val finalMeasurement = transformation.applySmartUserAssignment(measurement, values) - if (finalMeasurement != null) crud.saveMeasurement(finalMeasurement, values) + if (finalMeasurement != null) { + val finalValues = recomputeForUser?.invoke(finalMeasurement.userId) ?: values + crud.saveMeasurement(finalMeasurement, finalValues) + } } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt b/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt index df7530a45..fdb2c64b9 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt @@ -464,108 +464,7 @@ class BleConnector( return@launch } - 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 = 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, - MeasurementTypeKey.HEART_RATE to UnitType.BPM, - MeasurementTypeKey.IMPEDANCE to UnitType.OHM, - MeasurementTypeKey.IMPEDANCE_LOW to UnitType.OHM, - MeasurementTypeKey.ECW to UnitType.PERCENT, - MeasurementTypeKey.ICW to UnitType.PERCENT, - MeasurementTypeKey.PROTEIN to UnitType.PERCENT, - MeasurementTypeKey.BCM to UnitType.KG, - MeasurementTypeKey.BMR to UnitType.KCAL - ) - - val values = mutableListOf() - - /** - * 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 = converted - ) - ) - } - } - - /** - * Adds an integer value for the given key if present & valid. - * Used for heart rate which is stored as an Int. - */ - fun addConvertedIfValid( - value: Int?, - key: MeasurementTypeKey - ) { - val v = value ?: return - if (v <= 0) return - getTypeId(key)?.let { typeId -> - values.add( - MeasurementValue( - measurementId = 0, - typeId = typeId, - intValue = v - ) - ) - } - } - - // BMR goes in first so its presence guards the per-value recalc that - // runs after WEIGHT lands; otherwise DerivedValuesProcess would write - // its Mifflin-St Jeor BMR before the device's BIA-based BMR arrives, - // leaving two rows for the same typeId. - addConvertedIfValid(measurementData.bmr, MeasurementTypeKey.BMR) - - // 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) - addConvertedIfValid(measurementData.bone, MeasurementTypeKey.BONE) - addConvertedIfValid(measurementData.lbm, MeasurementTypeKey.LBM) - addConvertedIfValid(measurementData.heartRate, MeasurementTypeKey.HEART_RATE) - addConvertedIfValid(measurementData.impedance.toFloat(), MeasurementTypeKey.IMPEDANCE) - addConvertedIfValid(measurementData.impedanceLow.toFloat(), MeasurementTypeKey.IMPEDANCE_LOW) - addConvertedIfValid(measurementData.ecw, MeasurementTypeKey.ECW) - addConvertedIfValid(measurementData.icw, MeasurementTypeKey.ICW) - addConvertedIfValid(measurementData.protein, MeasurementTypeKey.PROTEIN) - addConvertedIfValid(measurementData.bcm, MeasurementTypeKey.BCM) + val values = buildBleValues(measurementData, types) if (values.isEmpty()) { LogManager.w(TAG, "No valid values from measurement of $deviceName to save.") @@ -578,11 +477,22 @@ class BleConnector( return@launch } + // Once the final user is resolved (smart weight-assignment inside the + // facade), re-derive body composition for THAT user via the active + // handler. BIA outputs (fat/water/muscle/…) depend on sex/age/height, + // so they must match the user the row is stored under — not whoever + // was merely selected when the scale broadcast. + val recomputeForUser: suspend (Int) -> List = { userId -> + val recomputed = activeCommunicator?.recomputeBodyCompositionForUser(measurementData, userId) + ?: measurementData + buildBleValues(recomputed, types) + } + try { - val measurementId = measurementFacade.saveMeasurementFromBleDevice(newDbMeasurement, values) + measurementFacade.saveMeasurementFromBleDevice(newDbMeasurement, values, recomputeForUser) LogManager.i( TAG, - "Measurement from $deviceName for User $currentAppUserId saved (ID: $measurementId). Values: ${values.size}" + "Measurement from $deviceName for User $currentAppUserId saved. Values: ${values.size}" ) pendingSavedCount.incrementAndGet() lastSavedArgs = listOf(measurementData.weight, deviceName) @@ -599,6 +509,109 @@ class BleConnector( } } + /** + * Flattens a [ScaleMeasurement] into persistable [MeasurementValue] rows, + * converting each field from its raw device unit to the configured target + * unit. Pure — reused for the initial save and for the post-assignment + * body-composition recompute. + */ + private fun buildBleValues( + measurementData: ScaleMeasurement, + types: List, + ): List { + 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 = 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, + MeasurementTypeKey.HEART_RATE to UnitType.BPM, + MeasurementTypeKey.IMPEDANCE to UnitType.OHM, + MeasurementTypeKey.IMPEDANCE_LOW to UnitType.OHM, + MeasurementTypeKey.ECW to UnitType.PERCENT, + MeasurementTypeKey.ICW to UnitType.PERCENT, + MeasurementTypeKey.PROTEIN to UnitType.PERCENT, + MeasurementTypeKey.BCM to UnitType.KG, + MeasurementTypeKey.BMR to UnitType.KCAL + ) + + val values = mutableListOf() + + 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 = converted + ) + ) + } + } + + fun addConvertedIfValid(value: Int?, key: MeasurementTypeKey) { + val v = value ?: return + if (v <= 0) return + getTypeId(key)?.let { typeId -> + values.add( + MeasurementValue( + measurementId = 0, + typeId = typeId, + intValue = v + ) + ) + } + } + + // BMR goes in first so its presence guards the per-value recalc that + // runs after WEIGHT lands; otherwise DerivedValuesProcess would write + // its Mifflin-St Jeor BMR before the device's BIA-based BMR arrives, + // leaving two rows for the same typeId. + addConvertedIfValid(measurementData.bmr, MeasurementTypeKey.BMR) + + // 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) + addConvertedIfValid(measurementData.bone, MeasurementTypeKey.BONE) + addConvertedIfValid(measurementData.lbm, MeasurementTypeKey.LBM) + addConvertedIfValid(measurementData.heartRate, MeasurementTypeKey.HEART_RATE) + addConvertedIfValid(measurementData.impedance.toFloat(), MeasurementTypeKey.IMPEDANCE) + addConvertedIfValid(measurementData.impedanceLow.toFloat(), MeasurementTypeKey.IMPEDANCE_LOW) + addConvertedIfValid(measurementData.ecw, MeasurementTypeKey.ECW) + addConvertedIfValid(measurementData.icw, MeasurementTypeKey.ICW) + addConvertedIfValid(measurementData.protein, MeasurementTypeKey.PROTEIN) + addConvertedIfValid(measurementData.bcm, MeasurementTypeKey.BCM) + + return values + } + /** * Disconnects from the currently connected device, if any. diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTransformationUseCase.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTransformationUseCase.kt index 9b9e07621..5e99b6585 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTransformationUseCase.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTransformationUseCase.kt @@ -88,14 +88,29 @@ class MeasurementTransformationUseCase @Inject constructor( measurement: Measurement, values: List ): Measurement? { + val newWeight = values.find { it.typeId == MeasurementTypeKey.WEIGHT.id }?.floatValue + ?: return measurement // No weight to assign, save as is + val resolvedUserId = resolveAssignedUserId(newWeight, measurement.userId) + ?: return null // Signal to ignore + return measurement.copy(userId = resolvedUserId) + } + + /** + * Resolves which user a weigh-in belongs to, applying "Smart User Assignment" + * (closest previous weight within tolerance) when enabled. Pure decision — no + * mutation — so the BLE pipeline can finalize the user BEFORE computing + * body-composition (which depends on the user's sex/age/height), and + * [applySmartUserAssignment] can reuse it. + * + * @param weightKg the new measured weight. + * @param fallbackUserId user to keep when smart-assignment is off, finds no + * candidate, or the best match is outside tolerance but not ignored. + * @return the resolved user id, or null when the measurement should be ignored. + */ + suspend fun resolveAssignedUserId(weightKg: Float, fallbackUserId: Int): Int? { val isSmartAssignment = settings.isSmartAssignmentEnabled.first() if (!isSmartAssignment) { - return measurement - } - - val newWeight = values.find { it.typeId == MeasurementTypeKey.WEIGHT.id }?.floatValue - if (newWeight == null) { - return measurement // No weight to assign, save as is + return fallbackUserId } // 1. Find candidates @@ -105,8 +120,8 @@ class MeasurementTransformationUseCase @Inject constructor( .firstNotNullOfOrNull { m -> m.values.find { v -> v.type.key == MeasurementTypeKey.WEIGHT }?.value?.floatValue } if (lastWeight != null && lastWeight > 0) { - val diffPercent = (abs(newWeight - lastWeight) / lastWeight) * 100 - LogManager.d("SmartAssignment", "User ${user.name}: last weight=$lastWeight, new=$newWeight, diff=${diffPercent.roundToInt()}%") + val diffPercent = (abs(weightKg - lastWeight) / lastWeight) * 100 + LogManager.d("SmartAssignment", "User ${user.name}: last weight=$lastWeight, new=$weightKg, diff=${diffPercent.roundToInt()}%") Triple(user, lastWeight, diffPercent) } else { null @@ -114,8 +129,8 @@ class MeasurementTransformationUseCase @Inject constructor( } if (userDiffs.isEmpty()) { - LogManager.i("SmartAssignment", "No users with previous weight found. Assigning to current user.") - return measurement + LogManager.i("SmartAssignment", "No users with previous weight found. Assigning to fallback user $fallbackUserId.") + return fallbackUserId } // 2. Find best candidate @@ -125,20 +140,19 @@ class MeasurementTransformationUseCase @Inject constructor( val tolerancePercent = settings.smartAssignmentTolerancePercent.first() val ignoreOutsideTolerance = settings.smartAssignmentIgnoreOutsideTolerance.first() - val winningUser: User? = if (bestMatch.third <= tolerancePercent) { - bestMatch.first + val winningUserId: Int? = if (bestMatch.third <= tolerancePercent) { + bestMatch.first.id } else { - if (ignoreOutsideTolerance) null else allUsers.find { it.id == measurement.userId } + if (ignoreOutsideTolerance) null else fallbackUserId } // 4. Return result - return if (winningUser != null) { - LogManager.i("SmartAssignment", "Assigning measurement to ${winningUser.name}.") - measurement.copy(userId = winningUser.id) + if (winningUserId != null) { + LogManager.i("SmartAssignment", "Assigning measurement to userId $winningUserId.") } else { LogManager.i("SmartAssignment", "Ignoring measurement as no user is within tolerance.") - null // Signal to ignore } + return winningUserId } /** diff --git a/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt b/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt index 2d6cb3ba9..5bb76b021 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt @@ -81,4 +81,73 @@ class MigrationTest { // The rewrite seeds the default measurement types. assertThat(repo.getAllMeasurementTypes().first()).isNotEmpty() } + + /** + * MIGRATION_15_16 re-derives S400 body-composition with each row's OWN user. + * Seeds a MALE row that wrongly carries female-profile body fat (30.08 %) plus + * the raw impedance bands, then asserts the migration rewrites it to the + * male re-derivation (~17.8 %) while leaving raw inputs untouched, and that a + * row with out-of-range impedance is left unchanged. + */ + @Test + fun migration15to16_reDerivesS400BodyCompositionForRowUser() { + val opened = RoomTestSupport.onDisk(context).also { db = it } + val sql = opened.openHelper.writableDatabase + + // --- Minimal type catalogue (raw; icon/unit values need not be valid enums here) --- + fun type(id: Int, key: String) = sql.execSQL( + "INSERT INTO MeasurementType (id,`key`,name,color,icon,unit,inputType,displayOrder,isDerived,isEnabled,isPinned,isOnRightYAxis,isInternal) " + + "VALUES ($id,'$key',NULL,0,'X','X','X',$id,0,1,0,0,0)" + ) + listOf( + 1 to "WEIGHT", 3 to "BODY_FAT", 4 to "WATER", 5 to "MUSCLE", 6 to "LBM", + 7 to "BONE", 12 to "VISCERAL_FAT", 21 to "BMR", 22 to "TDEE", + 29 to "IMPEDANCE", 30 to "IMPEDANCE_LOW", 31 to "ECW", 32 to "ICW", + 33 to "PROTEIN", 34 to "BCM", + ).forEach { (id, key) -> type(id, key) } + + // Male, 172 cm, born 1998-06-22 → age 28 at the measurement timestamp. + sql.execSQL( + "INSERT INTO User (id,name,icon,birthDate,gender,heightCm,activityLevel,useAssistedWeighing,amputations) " + + "VALUES (1,'dany','IC_DEFAULT',898560000000,'MALE',172.0,'MILD',0,'')" + ) + + fun value(measurementId: Int, typeId: Int, v: Float) = sql.execSQL( + "INSERT INTO MeasurementValue (measurementId,typeId,floatValue) VALUES ($measurementId,$typeId,$v)" + ) + + // Measurement 1: raw weight + dual-band impedance, but body-comp written + // with the WRONG (female) profile. + sql.execSQL("INSERT INTO Measurement (id,userId,timestamp) VALUES (1,1,1782735000000)") + value(1, 1, 72.9f) + value(1, 29, 455f) + value(1, 30, 410f) + value(1, 3, 30.08f) // wrong body fat + value(1, 4, 51.18f) // wrong water + value(1, 6, 50.97f) // wrong LBM + + // Measurement 2: impedance out of validated range → must stay untouched. + // Distinct timestamp to satisfy the unique (userId, timestamp) index. + sql.execSQL("INSERT INTO Measurement (id,userId,timestamp) VALUES (2,1,1782738600000)") + value(2, 1, 72.9f) + value(2, 29, 50f) + value(2, 30, 40f) + value(2, 3, 99f) // sentinel; migration must not overwrite + + MIGRATION_15_16.migrate(sql) + + fun read(measurementId: Int, typeId: Int): Float? = + sql.query("SELECT floatValue FROM MeasurementValue WHERE measurementId=$measurementId AND typeId=$typeId") + .use { c -> if (c.moveToFirst()) c.getFloat(0) else null } + + // Row 1 re-derived for the male user. + assertThat(read(1, 3)!!).isWithin(0.2f).of(17.8f) // BODY_FAT + assertThat(read(1, 4)!!).isWithin(0.5f).of(60.1f) // WATER + // Raw inputs untouched. + assertThat(read(1, 1)!!).isWithin(0.01f).of(72.9f) // WEIGHT + assertThat(read(1, 29)!!).isWithin(0.01f).of(455f) // IMPEDANCE + + // Row 2 left as-is (NOT_AVAILABLE inputs). + assertThat(read(2, 3)!!).isWithin(0.01f).of(99f) + } } diff --git a/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt b/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt index 76625436a..288191950 100644 --- a/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt +++ b/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt @@ -31,6 +31,7 @@ import com.health.openscale.core.database.MIGRATION_11_12 import com.health.openscale.core.database.MIGRATION_12_13 import com.health.openscale.core.database.MIGRATION_13_14 import com.health.openscale.core.database.MIGRATION_14_15 +import com.health.openscale.core.database.MIGRATION_15_16 import com.health.openscale.core.database.MIGRATION_6_7 import com.health.openscale.core.database.MIGRATION_7_8 import com.health.openscale.core.database.MIGRATION_8_9 @@ -75,7 +76,7 @@ object RoomTestSupport { /** The full, ordered migration chain — must mirror DatabaseModule.provideDatabase. */ val ALL_MIGRATIONS: Array = arrayOf( MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, - MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, + MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, ) /** On-disk database at the real [AppDatabase.DATABASE_NAME] path, with all migrations applied. */