Skip to content
Merged
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
Expand Up @@ -64,6 +64,7 @@ import com.health.openscale.core.bluetooth.scales.BodyConnectHandler
import com.health.openscale.core.bluetooth.scales.OkOkHandler
import com.health.openscale.core.bluetooth.scales.OmronWlcHandler
import com.health.openscale.core.bluetooth.scales.PicoocHandler
import com.health.openscale.core.bluetooth.scales.PicoocBroadcastHandler
import com.health.openscale.core.bluetooth.scales.OneByoneHandler
import com.health.openscale.core.bluetooth.scales.OneByoneNewHandler
import com.health.openscale.core.bluetooth.scales.QNHandler
Expand Down Expand Up @@ -138,6 +139,8 @@ class ScaleFactory @Inject constructor(
AndUC352BLEHandler(),
HealthKeep280Handler(),
PicoocHandler(),
// Exact PICOOC-L match must precede ScaleupHandler's generic 0xD0 manufacturer match.
PicoocBroadcastHandler(),
ActiveEraBF06Handler(),
AfuB1Handler(),
KeepS3Handler(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,10 @@ class OkOkV2Lib(
val bodyAge =
if (sex == MALE) (height * -0.7471f) + (weight * 0.9161f) + (age * 0.4184f) + (impedance * 0.0517f) + 54.2267f
else (height * -1.1165f) + (weight * 1.5784f) + (age * 0.4615f) + (impedance * 0.0415f) + 83.2548f
return min(18, max(bodyAge.toInt(), 80))
// The vendor's own clamp reads min(18, max(age, 80)), which can only ever return 18.
// The value was never published, so the slip stayed invisible; keep the intended
// 18..80 window now that it is.
return bodyAge.toInt().coerceIn(18, 80)
}

private fun getIdealWeight(): Float =
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,9 @@ object Wla25BodyComposition {
/**
* Metabolic age: the user's age nudged by a per-sex body-fat band.
*
* Currently unused — [com.health.openscale.core.bluetooth.data.ScaleMeasurement]
* has no field for it, so there is nowhere to publish it. `EtekcityLib` and
* `HesleyHandler` hit the same wall: one computes metabolic age and the
* other reads it off the wire, and both discard it. Kept here because it is
* part of the algorithm and is verified against the vendor library; wiring
* it up is a data-model change, not a driver one.
* Published by `RelaxmedicHandler` on the handler-local `ble.metabolic_age` type, the same
* identity the other drivers that report a body age use, so the column stays continuous
* across scales. Verified against the vendor library.
*
* The offsets skip zero — the healthy band steps straight from -1 to +1.
* The female band at [45, 46) returning +0 while >=46 gives +5 is not a
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.data.MeasurementType
import com.health.openscale.core.data.MeasurementTypeIcon
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
import com.health.openscale.core.service.ScannedDeviceInfo
import java.util.Date
Expand All @@ -38,6 +39,17 @@ import com.health.openscale.core.data.Percent
*/
class HesleyHandler : ScaleDeviceHandler() {

companion object {
/**
* Metabolic age the scale reports in byte 17. The identity stays vendor-neutral so a
* user switching brands keeps one continuous column.
*/
val METABOLIC_AGE = MeasurementType.deviceInt(
"metabolic_age", R.string.measurement_type_metabolic_age,
icon = MeasurementTypeIcon.IC_M_TIMER, color = 0xFF795548.toInt()
)
}

private val SERVICE: UUID = uuid16(0xFFF0)
private val CHAR_CMD: UUID = uuid16(0xFFF1) // write-only
private val CHAR_NOTIFY: UUID = uuid16(0xFFF4) // notify (+read on some firmwares)
Expand Down Expand Up @@ -86,7 +98,7 @@ class HesleyHandler : ScaleDeviceHandler() {
val water = (((frame[8].toInt() and 0xFF) shl 8) or (frame[9].toInt() and 0xFF)) / 10.0f
val muscle = (((frame[10].toInt() and 0xFF) shl 8) or (frame[11].toInt() and 0xFF)) / 10.0f
val bone = (((frame[12].toInt() and 0xFF) shl 8) or (frame[13].toInt() and 0xFF)) / 10.0f
// val bodyAge = frame[17].toInt() and 0xFF // 10..99 (unused)
val bodyAge = frame[17].toInt() and 0xFF // 10..99
// val kcal = (((frame[14].toInt() and 0xFF) shl 8) or (frame[15].toInt() and 0xFF)) // not stored

val m = ScaleMeasurement().apply {
Expand All @@ -96,6 +108,7 @@ class HesleyHandler : ScaleDeviceHandler() {
this[MeasurementType.MUSCLE] = Percent(muscle)
this[MeasurementType.WATER] = Percent(water)
this[MeasurementType.BONE] = Kg(bone)
bodyAge.takeIf { it in 10..99 }?.let { this[METABOLIC_AGE] = it }
}

logD( "Hesley result kg=${m[MeasurementType.WEIGHT]} fat=${m[MeasurementType.BODY_FAT]} water=${m[MeasurementType.WATER]} muscle=${m[MeasurementType.MUSCLE]} bone=${m[MeasurementType.BONE]}")
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.data.MeasurementType
import com.health.openscale.core.data.MeasurementTypeIcon
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
import com.health.openscale.core.bluetooth.data.ScaleUser
import com.health.openscale.core.bluetooth.libs.EtekcityLib
Expand Down Expand Up @@ -55,6 +56,18 @@ import com.health.openscale.core.data.Percent
*/
class HuaweiCH100SHandler : ScaleDeviceHandler() {

companion object {
/**
* Metabolic age the Chipsea BIA model derives together with the other composition
* values. The identity stays vendor-neutral so a user switching brands keeps one
* continuous column.
*/
val METABOLIC_AGE = MeasurementType.deviceInt(
"metabolic_age", R.string.measurement_type_metabolic_age,
icon = MeasurementTypeIcon.IC_M_TIMER, color = 0xFF795548.toInt()
)
}

// --- BLE identifiers ------------------------------------------------------

private val SERVICE = uuid16(0xFAA0)
Expand Down Expand Up @@ -288,6 +301,7 @@ class HuaweiCH100SHandler : ScaleDeviceHandler() {
this[MeasurementType.BONE] = Kg(lib.boneMass.toFloat())
this[MeasurementType.BMR] = Kcal(lib.basalMetabolicRate.toFloat())
this[MeasurementType.VISCERAL_FAT] = lib.visceralFat.toFloat()
lib.metabolicAge.takeIf { it in 10..99 }?.let { this[METABOLIC_AGE] = it }
}
}
publish(m)
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.data.MeasurementType
import com.health.openscale.core.data.MeasurementTypeIcon
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
import com.health.openscale.core.bluetooth.data.ScaleUser
import com.health.openscale.core.bluetooth.libs.KeepS3BodyComposition
Expand Down Expand Up @@ -644,12 +645,12 @@ class KeepS3Handler : ScaleDeviceHandler() {
// 15-60%). Keep's broader "muscle" value is FFM minus bone and can exceed that
// range, so publish the separately decoded skeletal-muscle percentage here.
this[MeasurementType.MUSCLE] = Percent(composition.skeletalMusclePercent)
// Keep's broader composition.musclePercent remains decoded by the model but is
// not published because openScale has no lean-soft-tissue measurement type.
// Keep's broader "muscle" (FFM minus bone) goes to its own type instead.
this[TOTAL_MUSCLE] = Percent(composition.musclePercent)
composition.bodyAge.takeIf { it in 10..99 }?.let { this[METABOLIC_AGE] = it }
// Not published yet — wiring them up would be handler-local now
// (MeasurementType.deviceFloat), but stays a deliberate follow-up.
// subcutaneousFat = composition.subcutaneousFatPercent
// bodyAge = composition.bodyAge
// bmi22ReferenceWeight = composition.bmi22ReferenceWeightKg
logI("Keep S3 body composition calculated with offline BHKeep SDK-compatible model")
}
Expand Down Expand Up @@ -786,6 +787,25 @@ class KeepS3Handler : ScaleDeviceHandler() {
}

companion object {
/**
* Metabolic age the offline BHKeep model computes. The identity stays vendor-neutral so
* a user switching brands keeps one continuous column.
*/
val METABOLIC_AGE = MeasurementType.deviceInt(
"metabolic_age", R.string.measurement_type_metabolic_age,
icon = MeasurementTypeIcon.IC_M_TIMER, color = 0xFF795548.toInt()
)

/**
* Keep's broad "muscle" figure: fat-free mass minus bone, as a percentage of weight.
* [MeasurementType.MUSCLE] is evaluated as skeletal muscle (plausible 15-60 %), which
* this value regularly exceeds, so it needs a type of its own.
*/
val TOTAL_MUSCLE = MeasurementType.devicePercent(
"total_muscle", R.string.measurement_type_total_muscle,
icon = MeasurementTypeIcon.IC_M_WORKOUT, color = 0xFF2E7D32.toInt()
)

private const val DEVICE_NAME = "Keep_S3"
private const val FINAL_RECORD_WAIT_MS = 2_000L
private const val DISCONNECT_DELAY_MS = 6_000L
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ package com.health.openscale.core.bluetooth.scales

import android.bluetooth.le.ScanResult
import android.util.SparseArray
import com.health.openscale.R
import com.health.openscale.core.data.MeasurementType
import com.health.openscale.core.data.MeasurementTypeIcon
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
import com.health.openscale.core.bluetooth.data.ScaleUser
import com.health.openscale.core.bluetooth.libs.OkOkV2Lib
Expand All @@ -42,6 +44,18 @@ import com.health.openscale.core.data.Percent
*/
class OkOkHandler : ScaleDeviceHandler() {

companion object {
/**
* Metabolic age from the V2 vendor model, published on the 0xC0 path where the
* composition is computed here. The identity stays vendor-neutral so a user switching
* brands keeps one continuous column.
*/
val METABOLIC_AGE = MeasurementType.deviceInt(
"metabolic_age", R.string.measurement_type_metabolic_age,
icon = MeasurementTypeIcon.IC_M_TIMER, color = 0xFF795548.toInt()
)
}

// Known manufacturer ids
private val MANUF_V20 = 0x20ca
private val MANUF_V11 = 0x11ca
Expand Down Expand Up @@ -167,6 +181,7 @@ class OkOkHandler : ScaleDeviceHandler() {
this[MeasurementType.BMR] = Kcal(lib.getBMR(kg))
this[MeasurementType.PROTEIN] = Percent(lib.getProtein(kg, imp))
this[MeasurementType.IMPEDANCE] = Ohm(imp)
lib.getBodyAge(kg, imp).takeIf { it in 10..99 }?.let { this[METABOLIC_AGE] = it }
})
return BroadcastAction.CONSUMED_STOP
}
Expand Down
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.BluetoothEvent.UserInteractionType
import com.health.openscale.core.data.MeasurementType
import com.health.openscale.core.data.MeasurementTypeIcon
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
import com.health.openscale.core.bluetooth.data.ScaleUser
import com.health.openscale.core.bluetooth.libs.OmronBodyCompositionLib
Expand Down Expand Up @@ -57,6 +58,16 @@ import com.health.openscale.core.data.Percent
class OmronWlcHandler : ScaleDeviceHandler() {

companion object {
/**
* Body age the scale stores alongside a record, on the models whose profile has the
* field. The identity stays vendor-neutral so a user switching brands keeps one
* continuous column.
*/
val METABOLIC_AGE = MeasurementType.deviceInt(
"metabolic_age", R.string.measurement_type_metabolic_age,
icon = MeasurementTypeIcon.IC_M_TIMER, color = 0xFF795548.toInt()
)

val SVC_OMRON_WLP: UUID = UUID.fromString("ecbe3980-c9a2-11e1-b1bd-0002a5d5c51b")
val CHR_UNLOCK: UUID = UUID.fromString("b305b680-aee7-11e1-a730-0002a5d5c51b")

Expand Down Expand Up @@ -502,6 +513,7 @@ class OmronWlcHandler : ScaleDeviceHandler() {
m[MeasurementType.MUSCLE] = Percent(skeletalMusclePercent ?: 0f)
m[MeasurementType.VISCERAL_FAT] = visceralFatLevel ?: 0f
m[MeasurementType.BMR] = Kcal(bmrKcal?.toFloat() ?: 0f)
bodyAgeYears?.takeIf { it in 10..99 }?.let { m[METABOLIC_AGE] = it }
}

// ---- chunked EEPROM reads ------------------------------------------------------------------
Expand Down
Loading