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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* openScale
* Copyright (C) 2026 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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? {
Expand Down Expand Up @@ -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)
}
}
}

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Pair<String, Int>>,
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) {
Expand Down
6 changes: 6 additions & 0 deletions android_app/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,12 @@
<string name="scale_configuration_title">Scale Configuration</string>
<string name="no_special_configuration_available">No additional special configuration available for this device.</string>

<!-- Mi Scale body-composition algorithm -->
<string name="mi_body_comp_algorithm_label">Body composition algorithm</string>
<string name="mi_algorithm_xiaomi">Xiaomi (original app)</string>
<string name="mi_algorithm_bodymiscale_science">Scientific</string>
<string name="mi_algorithm_description">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.</string>

<!-- S400 Scale Configuration -->
<string name="s400_bind_key_label">BLE Bind Key</string>
<string name="s400_bind_key_placeholder">32-character hex key</string>
Expand Down
Loading