diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt new file mode 100644 index 000000000..35c1742ab --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt @@ -0,0 +1,150 @@ +/* + * openScale + * Copyright (C) 2026 olie.xdev + * + * Portions derived from bodymiscale (C) dckiller51 and contributors, GPL-3.0 + * (https://github.com/dckiller51/bodymiscale). + * + * 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.data.GenderType + +/** + * Scientific mono-frequency (standard impedance) body-composition estimator, ported from the + * Home Assistant integration **bodymiscale** by dckiller51 (GPL-3.0): + * https://github.com/dckiller51/bodymiscale + * custom_components/bodymiscale/metrics/{impedance,weight}.py + util.py + * + * A hardware-calibrated LBM (the same baseline Xiaomi uses) is combined with peer-reviewed + * downstream formulas — body fat via the Siri (1956) 2-compartment model, water via the + * Pace & Rathbun (1945) constant, protein via Wang (1999), and BMR via the Schofield (WHO) + * equation. + * + * All metrics are chained and internally consistent: fat is derived from LBM, water and + * protein from fat/LBM, and muscle from fat and bone. Compute [getLbm] first and feed its + * result into the other methods (as the callers in bodymiscale do) to reproduce its output + * exactly. + * + * The reverse-engineered Zepp Life / Mi Fit algorithm is not reproduced here — openScale + * selects [MiScaleLib] for that path. The S400 dual-frequency mode of bodymiscale is likewise + * out of scope; openScale has its own dual-frequency path in [S400BodyComposition]. + */ +class BodyMiScaleLib( + private val gender: GenderType, + private val age: Int, + private val heightCm: Float, +) { + private val isMale = gender == GenderType.MALE + + /** + * Lean / fat-free body mass in kg — the Xiaomi hardware-calibrated formula, corrected + * for female profiles because the base regression has no sex term, and capped at 98% + * of body weight. Everything downstream depends on this. + */ + fun getLbm(weightKg: Float, impedance: Float): Float { + var lbm = (heightCm * 9.058f / 100f) * (heightCm / 100f) + + weightKg * 0.32f + 12.226f - impedance * 0.0068f - age * 0.0542f + + // bodymiscale 2026.8.0: its hardware regression lacks a sex term and overestimates + // female LBM by about 16%, skewing every metric derived from it. + if (!isMale) lbm *= FEMALE_LBM_CORRECTION + + return minOf(lbm, weightKg * 0.98f) + } + + /** Body fat percentage via the Siri (1956) 2-compartment model. Pass the [getLbm] result as [lbm]. */ + fun getFat(weightKg: Float, lbm: Float): Float { + val fat = (weightKg - lbm) / weightKg * 100f + val minimumFat = if (isMale) 5f else 10f + return fat.coerceIn(minimumFat, 75f) + } + + /** Water percentage of body weight, via the Pace & Rathbun (1945) 0.73 constant. */ + fun getWater(fatPercent: Float): Float = + ((100f - fatPercent) * 0.73f).coerceIn(35f, 73f) + + /** Protein percentage via Wang (1999): protein ≈ 19.5% of LBM. */ + fun getProtein(weightKg: Float, lbm: Float): Float = + (lbm * 0.195f / weightKg * 100f).coerceIn(5f, 32f) + + /** Bone mass in kg — empirical formula shared by all modes, driven by [getLbm]. */ + fun getBoneMass(lbm: Float): Float { + val base = if (isMale) 0.18016894f else 0.245691014f + var bone = (base - lbm * 0.05158f) * -1f + bone = if (bone > 2.2f) bone + 0.1f else bone - 0.1f + if ((isMale && bone > 5.2f) || (!isMale && bone > 5.1f)) bone = 8.0f + return bone.coerceIn(0.5f, 8f) + } + + /** + * Total muscle mass in kg: weight − fat mass − bone mass. Matches bodymiscale's + * "muscle_mass" sensor (the "Mięśnie" / Masa mięśniowa value), not skeletal muscle. + */ + fun getMuscleMass(weightKg: Float, fatPercent: Float, boneMassKg: Float): Float { + val muscle = weightKg - (fatPercent * 0.01f * weightKg) - boneMassKg + return muscle.coerceIn(10f, 120f) + } + + /** Basal metabolic rate in kcal/day via the Schofield (WHO) equation. */ + fun getBmr(weightKg: Float): Float = + schofieldBmr(weightKg).coerceIn(500f, 5000f) + + /** Schofield BMR by age bracket (WHO standard). */ + private fun schofieldBmr(weightKg: Float): Float { + val coeffs = if (isMale) MALE_SCHOFIELD else FEMALE_SCHOFIELD + val (slope, constant) = when { + age < 3 -> coeffs[0] + age < 10 -> coeffs[1] + age < 18 -> coeffs[2] + age < 30 -> coeffs[3] + age < 60 -> coeffs[4] + else -> coeffs[5] + } + return slope * weightKg + constant + } + + /** Visceral fat rating (Zepp Life formula, shared by all modes). */ + fun getVisceralFat(weightKg: Float): Float { + val h = heightCm + val w = weightKg + val vfal = if (isMale) { + if (h < w * 1.6f + 63.0f) + age * 0.15f + ((w * 305.0f) / ((h * 0.0826f * h - h * 0.4f) + 48.0f) - 2.9f) + else + age * 0.15f + (w * (h * -0.0015f + 0.765f) - h * 0.143f) - 5.0f + } else { + if (w <= h * 0.5f - 13.0f) + age * 0.07f + (w * (h * -0.0024f + 0.691f) - h * 0.027f) - 10.5f + else + age * 0.07f + ((w * 500.0f) / ((h * 1.45f + h * 0.1158f * h) - 120.0f) - 6.0f) + } + return vfal.coerceIn(1f, 50f) + } + + private companion object { + const val FEMALE_LBM_CORRECTION = 0.84f + + // Schofield (slope, constant) by bracket: 0-3, 3-10, 10-18, 18-30, 30-60, 60+ + val MALE_SCHOFIELD = arrayOf( + 59.512f to -30.4f, 22.706f to 504.3f, 17.686f to 658.2f, + 15.057f to 692.2f, 11.472f to 873.1f, 11.711f to 587.7f, + ) + val FEMALE_SCHOFIELD = arrayOf( + 58.317f to -31.1f, 20.315f to 485.9f, 13.384f to 692.6f, + 14.818f to 486.6f, 8.126f to 845.6f, 9.082f to 658.5f, + ) + } +} 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 bf7d96022..65ef7ab40 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 @@ -17,9 +17,11 @@ */ package com.health.openscale.core.bluetooth.scales +import androidx.compose.runtime.Composable 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.BodyMiScaleLib import com.health.openscale.core.bluetooth.libs.MiScaleLib import com.health.openscale.core.data.GenderType import com.health.openscale.core.service.ScannedDeviceInfo @@ -81,6 +83,36 @@ class MiScaleHandler : ScaleDeviceHandler() { // Timers private var historyFallbackJob: Job? = null + // ----- Body-composition algorithm selection (per-scale setting) ----- + + private val SETTINGS_KEY_ALGORITHM = "body_comp_algorithm" + + /** + * Which library derives body composition from this scale's mono-frequency impedance. + * [XIAOMI] is openScale's reverse-engineered Mi Fit port ([MiScaleLib]); [BODYMISCALE_SCIENCE] + * is the peer-reviewed estimator ported from the bodymiscale Home Assistant integration + * ([BodyMiScaleLib]). + */ + private enum class BodyCompAlgorithm { XIAOMI, BODYMISCALE_SCIENCE } + + private fun readBodyCompAlgorithm(): BodyCompAlgorithm = + runCatching { BodyCompAlgorithm.valueOf(settingsGetString(SETTINGS_KEY_ALGORITHM) ?: "") } + .getOrDefault(BodyCompAlgorithm.XIAOMI) + + @Composable + override fun DeviceConfigurationUi() { + SettingRadioGroup( + titleRes = R.string.mi_body_comp_algorithm_label, + key = SETTINGS_KEY_ALGORITHM, + options = listOf( + BodyCompAlgorithm.XIAOMI.name to R.string.mi_algorithm_xiaomi, + BodyCompAlgorithm.BODYMISCALE_SCIENCE.name to R.string.mi_algorithm_bodymiscale_science, + ), + defaultValue = BodyCompAlgorithm.XIAOMI.name, + descriptionRes = R.string.mi_algorithm_description, + ) + } + // ----- Capability & detection ----- override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { @@ -350,14 +382,10 @@ 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()) + when (readBodyCompAlgorithm()) { + BodyCompAlgorithm.XIAOMI -> applyXiaomiComposition(m, imp.toFloat(), user) + BodyCompAlgorithm.BODYMISCALE_SCIENCE -> applyBodyMiScaleComposition(m, imp.toFloat(), user) + } } } @@ -367,6 +395,43 @@ class MiScaleHandler : ScaleDeviceHandler() { return true } + /** openScale's reverse-engineered Mi Fit algorithm (the original-app parity path). */ + private fun applyXiaomiComposition(m: ScaleMeasurement, impedance: Float, user: ScaleUser) { + val sex = if (user.gender == GenderType.MALE) 1 else 0 + val lib = MiScaleLib(sex, user.age, user.bodyHeight) + m.water = lib.getWater(m.weight, impedance) + m.visceralFat = lib.getVisceralFat(m.weight) + m.fat = lib.getBodyFat(m.weight, impedance) + m.muscle = lib.getMuscle(m.weight, impedance) + m.lbm = lib.getLBM(m.weight, impedance) + m.bone = lib.getBoneMass(m.weight, impedance) + } + + /** + * Scientific estimator; see [BodyMiScaleLib] for attribution and formula sources. + * + * Fields follow the same units as [applyXiaomiComposition]: fat/water/muscle/protein are + * percentages of body weight, bone/lbm are kg, bmr is kcal/day. Muscle mass (kg) is + * converted to a percentage to match the schema and the Xiaomi path. + */ + private fun applyBodyMiScaleComposition(m: ScaleMeasurement, impedance: Float, user: ScaleUser) { + val lib = BodyMiScaleLib(user.gender, user.age, user.bodyHeight) + + val lbmKg = lib.getLbm(m.weight, impedance) + val fatPct = lib.getFat(m.weight, lbmKg) + val boneKg = lib.getBoneMass(lbmKg) + val muscleKg = lib.getMuscleMass(m.weight, fatPct, boneKg) + + m.fat = fatPct + m.water = lib.getWater(fatPct) + m.muscle = if (m.weight > 0f) muscleKg / m.weight * 100f else 0f + m.lbm = lbmKg + m.bone = boneKg + m.protein = lib.getProtein(m.weight, lbmKg) + m.bmr = lib.getBmr(m.weight) + m.visceralFat = lib.getVisceralFat(m.weight) + } + /** 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/ScaleDeviceHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt index 7c8c755bf..6a17f6854 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 @@ -19,8 +19,12 @@ package com.health.openscale.core.bluetooth.scales import android.bluetooth.le.ScanResult import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.selection.selectable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AutoGraph import androidx.compose.material.icons.filled.FitnessCenter @@ -30,12 +34,18 @@ import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.outlined.BatteryStd import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.health.openscale.R import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType import com.health.openscale.core.bluetooth.data.ScaleMeasurement @@ -139,6 +149,52 @@ abstract class ScaleDeviceHandler { } } + /** + * A titled radio-button group backed by a string setting. Reads the persisted value for + * [key] (falling back to [defaultValue]) and writes the selection back on each change. + * Each option is a stored value paired with its label string resource. + */ + @Composable + protected fun SettingRadioGroup( + @StringRes titleRes: Int, + key: String, + options: List>, + defaultValue: String, + @StringRes descriptionRes: Int? = null, + ) { + val persisted = settingsGetString(key) ?: defaultValue + var selected by remember(persisted) { mutableStateOf(persisted) } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = stringResource(titleRes), + style = MaterialTheme.typography.titleSmall, + ) + options.forEach { (value, labelRes) -> + val onSelect = { + selected = value + settingsPutString(key, value) + } + Row( + modifier = Modifier + .fillMaxWidth() + .selectable(selected = selected == value, onClick = onSelect), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected == value, onClick = onSelect) + Text(stringResource(labelRes)) + } + } + if (descriptionRes != null) { + Text( + text = stringResource(descriptionRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + // --- Lifecycle entry points called by the adapter ------------------------- internal fun attachSettings(settings: DriverSettings) { diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 1fe8a5c50..7adea8155 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -394,6 +394,12 @@ Scale Configuration No additional special configuration available for this device. + + Body composition algorithm + Xiaomi (original app) + Scientific + Choose how body composition is derived from impedance. \"Xiaomi\" reproduces the original Mi Fit / Zepp Life app. \"Scientific\" uses peer-reviewed formulas (Siri, Pace, Wang, Schofield) and also reports protein and BMR. Applies to new measurements from this scale. + BLE Bind Key 32-character hex key diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt new file mode 100644 index 000000000..4787cec47 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt @@ -0,0 +1,86 @@ +/* + * openScale + * Copyright (C) 2026 olie.xdev + * + * Portions derived from bodymiscale (C) dckiller51 and contributors, GPL-3.0 + * (https://github.com/dckiller51/bodymiscale). + * + * 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.google.common.truth.Truth.assertThat +import com.health.openscale.core.data.GenderType +import org.junit.Test + +/** + * Unit tests for [BodyMiScaleLib]. + * + * The regression fixture is a real measurement captured from the upstream bodymiscale + * Home Assistant integration (male, 46y, 168cm, 86.10kg, impedance 421), so this test + * locks openScale's port to byte-for-byte parity with the reference project. + */ +class BodyMiScaleLibTest { + private val EPS = 0.05f + + private val lib = BodyMiScaleLib(GenderType.MALE, age = 46, heightCm = 168f) + private val weight = 86.10f + private val impedance = 421f + + @Test + fun matches_bodymiscale_reference() { + val lbm = lib.getLbm(weight, impedance) + val fat = lib.getFat(weight, lbm) + val water = lib.getWater(fat) + val bone = lib.getBoneMass(lbm) + val muscle = lib.getMuscleMass(weight, fat, bone) + val protein = lib.getProtein(weight, lbm) + + assertThat(lbm).isWithin(EPS).of(60.0f) + assertThat(fat).isWithin(EPS).of(30.33f) + assertThat(water).isWithin(EPS).of(50.86f) + assertThat(protein).isWithin(EPS).of(13.59f) + assertThat(bone).isWithin(EPS).of(3.01f) + assertThat(muscle).isWithin(EPS).of(56.97f) + assertThat(lib.getBmr(weight)).isWithin(1f).of(1861f) + assertThat(lib.getVisceralFat(weight)).isWithin(EPS).of(15.36f) + } + + @Test + fun fat_reacts_to_impedance_only_weakly() { + // Xiaomi-calibrated LBM barely moves with impedance; verify direction is sane. + val fatLow = lib.getFat(weight, lib.getLbm(weight, 400f)) + val fatHigh = lib.getFat(weight, lib.getLbm(weight, 600f)) + assertThat(fatHigh).isGreaterThan(fatLow) + } + + @Test + fun female_profile_applies_bodymiscale_lbm_correction() { + val femaleLib = BodyMiScaleLib(GenderType.FEMALE, age = 46, heightCm = 168f) + + val lbm = femaleLib.getLbm(weight, impedance) + val fat = femaleLib.getFat(weight, lbm) + + assertThat(lbm).isWithin(EPS).of(50.40f) + assertThat(fat).isWithin(EPS).of(41.46f) + } + + @Test + fun fat_uses_sex_specific_biological_floor() { + val femaleLib = BodyMiScaleLib(GenderType.FEMALE, age = 46, heightCm = 168f) + + assertThat(lib.getFat(100f, 99f)).isEqualTo(5f) + assertThat(femaleLib.getFat(100f, 99f)).isEqualTo(10f) + } +}