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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ interface ScaleCommunicator {
/** Stream of [BluetoothEvent] emitted by the communicator. */
fun getEventsFlow(): SharedFlow<BluetoothEvent>

/**
* 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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading