diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index b36a22ab2..347d020ce 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -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 @@ -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(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OkOkV2Lib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OkOkV2Lib.kt index 6ec5125b2..db115800f 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OkOkV2Lib.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OkOkV2Lib.kt @@ -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 = diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/PicoocWhiteBodyComposition.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/PicoocWhiteBodyComposition.kt new file mode 100644 index 000000000..305da59b7 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/PicoocWhiteBodyComposition.kt @@ -0,0 +1,639 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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. + */ +package com.health.openscale.core.bluetooth.libs + +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Kotlin implementation of PICOOC 4.3.0's reverse-engineered Caucasian/White BIA calculations. + * + * The vendor names the two pieces of persistent profile state `anchor_weight` and + * `anchor_bata` (sic). They are not scale calibration constants: the app derives them from a + * group of nearby historical weigh-ins. Keeping them explicit makes the calculation testable + * and lets a handler persist them without shipping PICOOC's proprietary native library. + */ +internal object PicoocWhiteBodyComposition { + data class Input( + val male: Boolean, + val heightCm: Float, + val age: Int, + val weightKg: Float, + val correctedImpedanceOhm: Int, + val anchorWeightKg: Int, + val anchorBeta: Int, + val hour: Int = 0, + val previousMeasurementAnchor: Int = 0, + ) + + data class Result( + val bodyFatPercent: Float, + /** PICOOC's broad muscle metric: lean body mass minus bone mass, as % of weight. */ + val totalMusclePercent: Float, + val boneMassKg: Float, + val waterPercent: Float, + val basalMetabolicRateKcal: Int, + val proteinPercent: Float, + val bmi: Float, + val visceralFatLevel: Int, + val leanBodyMassKg: Float, + val metabolicAge: Int, + val skeletalMusclePercent: Float, + val fatReferencePercent: Float, + val anchorBeta: Int, + val measurementAnchor: Int, + ) + + fun calculate(input: Input): Result? { + val h = if (input.heightCm < 3f) input.heightCm * 100f else input.heightCm + val w = input.weightKg + val r = input.correctedImpedanceOhm + if (h <= 0f || w <= 0f || r < 50 || input.age < 16 || input.anchorWeightKg <= 0) { + return null + } + + val sex = if (input.male) 1 else 0 + val reference = fatReference(h, w, sex, input.age, r, input.anchorWeightKg) + val beta = if (input.anchorBeta >= 19) { + input.anchorBeta + } else { + initialAnchorBeta( + sex = sex, + age = input.age, + bmiTimesTen = (w / square(h / 100f) * 10f).roundToInt(), + referenceFatTimesTen = (reference * 10f).roundToInt(), + impedance = r, + hour = input.hour, + ) + } + + val initialCoefficient = if (input.male) 0.495f else 0.515f + var fat = fatFormula(h, w, initialCoefficient, sex, input.age, r, input.anchorWeightKg, beta) + var anchorPart = 0 + + // PICOOC has a small continuity correction only at exceptionally low fat values. The + // previous measurement stores beta*10 + this one-digit band number. + val previousPart = if (input.previousMeasurementAnchor > 100) { + input.previousMeasurementAnchor % 10 + } else { + 0 + } + val thresholds = if (input.male) { + shiftedThresholds(floatArrayOf(5f, 6f, 7f, 8f, 9f), previousPart) + } else { + shiftedThresholds(floatArrayOf(6f, 7f, 8f, 9f, 10f, 11f), previousPart) + } + val coefficients = if (input.male) { + floatArrayOf(0.435f, 0.445f, 0.455f, 0.465f, 0.475f) + } else { + floatArrayOf(0.475f, 0.4825f, 0.49f, 0.495f, 0.5f, 0.505f) + } + for (index in thresholds.indices) { + if (fat <= thresholds[index]) { + anchorPart = index + 1 + fat = fatFormula(h, w, coefficients[index], sex, input.age, r, input.anchorWeightKg, beta) + break + } + } + + val lean = w * (1f - fat / 100f) + val water = water(h, w, fat, sex, input.age, r) + val bone = if (input.male) lean * 0.05f + w * 0.00237f else lean * 0.0868f - w * 0.02085f + val totalMuscle = 100f - fat - bone / w * 100f + val protein = 100f - fat - water - bone / w * 100f + val bmr = bmr(h, w, lean, fat, sex, input.age) + val bmi = w / square(h / 100f) + val visceral = visceralFat(h, w, input.age, r) + val rawBodyAge = rawBodyAge(w, h, fat, sex, input.age) + val metabolicAge = adjustedBodyAge(input.age, sex, bmr, w, rawBodyAge) + val skeletal = skeletalMuscle(h, w, sex, input.age, r) + + return Result( + bodyFatPercent = fat, + totalMusclePercent = totalMuscle, + boneMassKg = bone, + waterPercent = water, + basalMetabolicRateKcal = bmr, + proteinPercent = protein, + bmi = bmi, + visceralFatLevel = visceral, + leanBodyMassKg = lean, + metabolicAge = metabolicAge, + skeletalMusclePercent = skeletal, + fatReferencePercent = reference, + anchorBeta = beta, + measurementAnchor = beta * 10 + anchorPart, + ) + } + + /** Vendor impedance stabilisation: reuse a recent correction, otherwise round to 10 ohms. */ + fun correctedImpedance( + rawOhm: Int, + weightKg: Float, + timestampMs: Long, + previousRawOhm: Int?, + previousWeightKg: Float?, + previousTimestampMs: Long?, + previousCorrectedOhm: Int?, + ): Int { + val canReuse = previousRawOhm != null && previousWeightKg != null && + previousTimestampMs != null && timestampMs - previousTimestampMs in 0L..600_000L && + abs(weightKg - previousWeightKg) <= 1f && abs(rawOhm - previousRawOhm) < 10 + if (canReuse && previousCorrectedOhm != null && previousCorrectedOhm > 0) { + return previousCorrectedOhm + } + val source = if (canReuse) previousRawOhm else rawOhm + return ((source / 10.0) + 0.5).toInt() * 10 + } + + /** Vendor anchor bucket: keep it over [-1,+2) kg, otherwise reset to truncated weight. */ + fun anchorWeight(weightKg: Float, previousAnchorWeightKg: Int?): Int { + val previous = previousAnchorWeightKg?.takeIf { it > 0 } ?: return weightKg.toInt() + val tenths = (weightKg * 10f).roundToInt() + return if (tenths >= (previous + 2) * 10 || tenths < (previous - 1) * 10) { + weightKg.toInt() + } else { + previous + } + } + + private fun fatReference(h: Float, w: Float, sex: Int, age: Int, r: Int, anchorWeight: Int): Float { + fun youngerLean(): Double { + val intercept = if (sex == 1) 9.28 else 10.985 + val offset = if (sex == 1) 0.7335 else 0.7785 + val coefficient = if (sex == 1) 0.495 else 0.515 + val fatRatio = (h * -0.0767 + intercept + age * 0.0635) / anchorWeight + offset - + coefficient * h * h / (r * w) + r * 0.00001732 - r * 0.01 / w + return w * (1.0 - fatRatio) + } + + val leanKg = when { + age < 51 -> youngerLean() + else -> { + val olderLean = if (sex == 1) { + h * 0.499 * h / r + 15.229 + w * 0.134 + } else { + h * 0.45954 * h / r - 2.66775 + w * 0.204 + h * 0.05113 + + r * 0.00667 - age * 0.04233 + } + if (age < 61) (60 - age) * youngerLean() / 10.0 + (age - 50) * olderLean / 10.0 else olderLean + } + } + return boundedFat(((w - leanKg) / w * 100.0).toFloat(), sex) + } + + private fun fatFormula( + h: Float, + w: Float, + coefficient: Float, + sex: Int, + age: Int, + r: Int, + anchorWeight: Int, + beta: Int, + ): Float { + val intercept = if (sex == 1) 9.28 else 10.985 + val offset = if (sex == 1) 0.7335 else 0.7785 + val ratio = (h * -0.0767 + intercept + age * 0.0635) / anchorWeight + offset - + coefficient * h * h / (r * w) + r * 0.00001732 - beta / 100_000_000.0 * r * r + return boundedFat((ratio * 100.0).toFloat(), sex) + } + + private fun boundedFat(value: Float, sex: Int): Float = if (sex == 1) { + value.coerceIn(5f, 62.2f) + } else { + value.coerceIn(5f, 66.7f) + } + + private fun initialAnchorBeta( + sex: Int, + age: Int, + bmiTimesTen: Int, + referenceFatTimesTen: Int, + impedance: Int, + hour: Int, + ): Int { + val lowBmi = bmiTimesTen < 250 + val lowReferenceFat = referenceFatTimesTen < 290 + val daytime = hour in 4..11 + val band = if (lowBmi) { + when { + impedance < 500 -> 0 + impedance < 550 -> 1 + impedance < 600 -> 2 + impedance < 650 -> 3 + impedance < 700 -> 4 + else -> 5 + } + } else { + when { + impedance < 400 -> 0 + impedance < 500 -> 1 + impedance < 550 -> 2 + impedance < 600 -> 3 + impedance < 650 -> 4 + impedance < 700 -> 5 + else -> 6 + } + } + + val values = when { + age < 51 && sex == 1 && lowReferenceFat && lowBmi -> + pair(intArrayOf(35, 33, 31, 30, 29, 28), intArrayOf(35, 35, 33, 31, 30, 29)) + age < 51 && sex == 1 && lowReferenceFat -> + pair(intArrayOf(35, 34, 32, 30, 29, 28, 27), intArrayOf(35, 35, 34, 32, 30, 29, 28)) + age < 51 && sex == 1 && lowBmi -> + pair(intArrayOf(25, 24, 23, 22, 21, 20), intArrayOf(25, 25, 24, 23, 22, 21)) + age < 51 && sex == 1 -> + pair(intArrayOf(25, 24, 23, 22, 21, 20, 19), intArrayOf(25, 25, 24, 23, 22, 21, 20)) + age < 51 && lowReferenceFat && lowBmi -> + pair(intArrayOf(38, 36, 35, 34, 33, 32), intArrayOf(38, 38, 36, 35, 34, 33)) + age < 51 && lowReferenceFat -> + pair(intArrayOf(38, 37, 35, 34, 33, 32, 31), intArrayOf(38, 38, 37, 35, 34, 33, 32)) + age < 51 && lowBmi -> + pair(intArrayOf(31, 30, 29, 28, 27, 26), intArrayOf(31, 31, 30, 29, 28, 27)) + age < 51 -> + pair(intArrayOf(31, 30, 29, 28, 27, 26, 25), intArrayOf(31, 31, 30, 29, 28, 27, 26)) + sex == 1 && lowReferenceFat && lowBmi -> + pair(intArrayOf(36, 35, 34, 33, 31, 29), intArrayOf(36, 36, 35, 34, 33, 31)) + sex == 1 && lowReferenceFat -> + pair(intArrayOf(36, 35, 34, 33, 32, 30, 28), intArrayOf(36, 36, 35, 34, 33, 32, 30)) + sex == 1 && lowBmi -> + pair(intArrayOf(25, 24, 23, 22, 21, 20), intArrayOf(25, 25, 24, 23, 22, 21)) + sex == 1 -> + pair(intArrayOf(25, 24, 23, 22, 21, 20, 19), intArrayOf(25, 25, 24, 23, 22, 21, 20)) + lowReferenceFat && lowBmi -> + pair(intArrayOf(41, 39, 37, 35, 34, 33), intArrayOf(41, 41, 39, 37, 35, 34)) + lowReferenceFat -> + pair(intArrayOf(41, 38, 37, 36, 34, 33, 32), intArrayOf(41, 41, 38, 37, 36, 34, 33)) + lowBmi -> + pair(intArrayOf(30, 29, 28, 27, 26, 25), intArrayOf(30, 30, 29, 28, 27, 26)) + else -> + pair(intArrayOf(30, 29, 28, 27, 26, 25, 24), intArrayOf(30, 30, 29, 28, 27, 26, 25)) + } + return values[if (daytime) 1 else 0][band] + } + + private fun pair(normal: IntArray, daytime: IntArray): Array = arrayOf(normal, daytime) + + private fun shiftedThresholds(base: FloatArray, previousPart: Int): FloatArray = + base.copyOf().also { values -> + if (previousPart in 1..values.size) values[previousPart - 1] += 1f + } + + private fun water(h: Float, w: Float, fat: Float, sex: Int, age: Int, r: Int): Float { + val value = ((h * 0.3674 * h / r + 6.53 + w * 0.17531 - age * 0.11 + sex * 2.83) / w * 100.0).toFloat() + return value.coerceAtMost(if (sex == 1) 73.8f else 72.8f) + } + + private fun bmr(h: Float, w: Float, lean: Float, fat: Float, sex: Int, age: Int): Int { + val equationOne = if (sex == 1) { + when { + age < 4 -> w * 28.2 + h * 8.59 - 371.0 + age < 11 -> w * 15.1 + h * 3.13 + 306.0 + age < 18 -> w * 15.6 + h * 2.66 + 299.0 + age < 31 -> w * 14.4 + h * 3.13 + 113.0 + age < 61 -> w * 11.4 + h * 5.41 - 137.0 + else -> w * 11.4 + h * 5.41 - 256.0 + } + } else { + when { + age < 4 -> w * 30.4 + h * 7.03 - 287.0 + age < 11 -> w * 15.9 + h * 2.1 + 349.0 + age < 18 -> w * 9.4 + h * 2.49 + 462.0 + age < 31 -> w * 10.4 + h * 6.15 - 282.0 + age < 61 -> w * 8.18 + h * 5.02 - 11.6 + else -> w * 8.52 + h * 4.21 + 10.7 + } + } + val fatKg = w * fat / 100f + val bmi = w / square(h / 100f) + val equationTwo = when { + bmi <= 18.5f -> (lean * 0.08961 + fatKg * 0.05662 + 0.667) * 1000 / 4.184 + bmi <= 25f -> (lean * 0.0455 + fatKg * 0.0278 - age * 0.01291 + + if (sex == 1) 4.513 else 3.634) * 1000 / 4.184 + bmi <= 30f -> (lean * 0.03776 + fatKg * 0.03013 - age * 0.01196 + + if (sex == 1) 4.858 else 3.928) * 1000 / 4.814 + else -> (lean * 0.05685 + fatKg * 0.04022 - age * 0.01402 + + if (sex == 1) 3.626 else 2.818) * 1000 / 4.814 + } + val averaged = ((equationOne + equationTwo) / 2.0).roundToInt() + val leanFloor = (lean * 21.6 + 370.0).toInt() + return maxOf(averaged, leanFloor) + } + + private fun visceralFat(h: Float, w: Float, age: Int, r: Int): Int { + // The mangled signature is (int, float, float, int): age is in x0 and resistance in + // x1. Ghidra lists the float registers first, which can misleadingly resemble sex/age. + val raw = ((r * 31f + w * 1_000_000f * 10f / (h * 100f * h) * 940f + + age * 1049f - 210_772f) / 1000f).toInt().coerceIn(0, 0xffff) + return (raw / 10 + 1).coerceIn(1, 30) + } + + private fun skeletalMuscle(h: Float, w: Float, sex: Int, age: Int, r: Int): Float { + val value = if (sex == 1) { + (h * 0.3315 * h / r + w * 0.119 - age * 0.0355 + 4.4509) / w * 100.0 + } else { + (h * 0.3475 * h / r + w * 0.0778 - age * 0.0355 + 3.1369) / w * 100.0 + } + return value.toFloat().coerceIn(if (sex == 1) 20.1f else 15.1f, if (sex == 1) 70.1f else 68.1f) + } + + private fun rawBodyAge(w: Float, h: Float, fat: Float, sex: Int, age: Int): Int { + if (age < 18) return 0 + val weightIndex = ((w * 10f).toInt() - 50) / 10 + val heightIndex = ((h * 10f).toInt() - 1000) / 5 + val weightFactor = WEIGHT_TABLE[weightIndex.coerceIn(0, WEIGHT_TABLE.lastIndex)] + val heightFactor = if (heightIndex < HEIGHT_TABLE_SHORT.size) { + HEIGHT_TABLE_SHORT[heightIndex.coerceAtLeast(0)] + } else { + HEIGHT_TABLE_TALL[(heightIndex - HEIGHT_TABLE_SHORT.size).coerceIn(0, HEIGHT_TABLE_TALL.lastIndex)] + } + val impedanceLike = ((1000f - fat * 10f) * w * 10f * 100_000f / + (weightFactor * heightFactor) / 8883f) + val signal = if (sex == 0) { + fat * 10f * 10.3f + 6716f - impedanceLike * 313f + } else { + fat * 10f * 3.86f + 3052f - impedanceLike * 100f + } + val estimate = if (sex == 0) age * 9f + abs(signal) * 0.05f else age * 8f + abs(signal) * 0.1f + return (estimate / 10f + 0.5f).toInt().coerceIn(18, 80) + } + + private fun adjustedBodyAge(age: Int, sex: Int, bmr: Int, weight: Float, rawAge: Int): Int { + var adjusted = age + if (age > 17) { + val ideal = idealBmr(weight, sex, age) + adjusted = if (age < 26) { + val lowRatio = if (sex == 1) 0.9f else 0.95f + when { + bmr >= ideal -> age + bmr < ideal * 0.5f -> age + if (sex == 1) 5 else 8 + bmr < ideal * lowRatio -> age + interpolate(bmr, ideal * 0.5f, ideal * lowRatio, 7, 3) + else -> age + interpolate(bmr, ideal * lowRatio, ideal.toFloat(), 3, 1) + } + } else if (sex == 1) { + when { + bmr >= ideal * 1.15f -> age - 3 + bmr >= ideal * 1.10f -> age - interpolate(bmr, ideal * 1.10f, ideal * 1.15f, 1, 2) + bmr >= ideal -> age + bmr >= ideal * 0.9f -> age + interpolate(bmr, ideal * 0.9f, ideal.toFloat(), 4, 1) + bmr >= ideal * 0.5f -> age + interpolate(bmr, ideal * 0.5f, ideal * 0.9f, 8, 4) + else -> age + 9 + } + } else { + when { + bmr >= ideal * 1.10f -> age - 3 + bmr >= ideal * 1.05f -> age - interpolate(bmr, ideal * 1.05f, ideal * 1.10f, 1, 2) + bmr >= ideal -> age + bmr >= ideal * 0.95f -> age + interpolate(bmr, ideal * 0.95f, ideal.toFloat(), 4, 1) + bmr >= ideal * 0.5f -> age + interpolate(bmr, ideal * 0.5f, ideal * 0.95f, 8, 4) + else -> age + 9 + } + } + } + adjusted += ((rawAge - adjusted) / 2f).toInt() + return adjusted + } + + private fun idealBmr(weight: Float, sex: Int, age: Int): Int { + val value = when { + age < 10 -> weight * if (sex == 1) 42.48 else 40.176 + age < 13 -> weight * if (sex == 1) 35.136 else 33.264 + age < 16 -> weight * if (sex == 1) 29.52 else 27.936 + age < 20 -> if (sex == 1 && weight > 73.5f) weight * 17.5 + 651 else weight * if (sex == 1) 26.352 else 24.192 + age < 25 -> if (sex == 1 && weight > 77.6f) weight * 15.3 + 679 else weight * if (sex == 1) 24.048 else 23.328 + age < 30 -> if (sex == 1 && weight > 89.4f) weight * 15.3 + 679 else weight * if (sex == 1) 22.896 else 22.032 + age < 35 -> if (sex == 1 && weight > 77.8f) weight * 11.6 + 879 else weight * if (sex == 1) 22.896 else 22.032 + age < 55 -> if (sex == 1 && weight > 83.1f) weight * 11.6 + 879 else weight * if (sex == 1) 22.176 else 21.168 + age < 70 -> if (sex == 1 && weight > 86.7f) weight * 11.6 + 879 else weight * if (sex == 1) 21.744 else 20.736 + else -> weight * if (sex == 1) 20.88 else 20.736 + } + return (value + 0.5).toInt() + } + + private fun interpolate(value: Int, low: Float, high: Float, lowResult: Int, highResult: Int): Int { + val clamped = value.toFloat().coerceIn(low, high) + return (lowResult + (clamped - low) / (high - low) * (highResult - lowResult) + 0.5f).toInt() + } + + private fun square(value: Float): Float = value * value + + private val WEIGHT_TABLE = intArrayOf( + 20,22,24,25,27,28,29,30,31,32,33,34,35,36,37,38,39,39,40,41,42,42,43,44,45,45,46,47,47,48,48,49, + 50,50,51,51,52,53,53,54,54,55,55,56,56,57,57,58,58,59,59,60,60,61,61,62,62,62,63,63,64,64,65,65, + 66,66,66,67,67,68,68,68,69,69,70,70,70,71,71,72,72,72,73,73,73,74,74,74,75,75,76,76,76,77,77,77, + 78,78,78,79,79,79,80,80,80,81,81,81,82,82,82,83,83,83,83,84,84,84,85,85,85,86,86,86,87,87,87,87, + 88,88,88,89,89,89,89,90,90,90,91,91,91,91,92,92,92,93,93,93,93,94,94,94,94,95,95,95,95,95,95, + ) + + private val HEIGHT_TABLE_SHORT = intArrayOf( + 2118,2125,2132,2139,2146,2153,2160,2167,2174,2181,2188,2195,2202,2209,2216,2222,2229,2236,2243,2250, + 2257,2263,2270,2277,2284,2290,2297,2304,2311,2317,2324,2331,2337,2344,2351,2357,2364,2371,2377,2384, + 2391,2397,2404,2410,2417,2423,2430,2437,2443,2450,2456,2463,2469,2476,2482,2489,2495,2502,2508,2514, + 2521,2527,2534,2540,2546,2553,2559,2566,2572,2578,2585,2591,2597,2604,2610,2616,2623,2629,2635,2642, + 2648,2654,2660,2667,2673,2679,2685,2691,2698,2704,2710,2716,2722,2729,2735,2741,2747,2753,2759,2766, + 2772,2778,2784,2790,2796,2802,2808,2814,2821,2827,2833,2839,2845,2851,2857,2863,2869,2875,2881,2887,2893, + ) + + private val HEIGHT_TABLE_TALL = intArrayOf( + 2899,2905,2911,2917,2923,2929,2935,2941,2947,2953,2958,2964,2970,2976,2982,2988,2994,3000,3006,3012, + 3017,3023,3029,3035,3041,3047,3052,3058,3064,3070,3076,3082,3087,3093,3099,3105,3111,3116,3122,3128, + 3134,3139,3145,3151,3157,3162,3168,3174,3179,3185,3191,3197,3202,3208,3214,3219,3225,3231,3236,3242, + 3248,3253,3259,3265,3270,3276,3281,3287,3293,3298,3304,3310,3315,3321,3326,3332,3337,3343,3349,3354, + 3360,3365,3371,3376,3382,3387,3393,3398,3404,3410,3415,3421,3426,3432,3437,3443,3448,3453,3459,3464, + 3470,3475,3481,3486,3492,3497,3503,3508,3513,3519,3524,3530,3535,3541,3546,3551,3557,3562,3568,3573, + ) +} + +/** + * Online equivalent of PICOOC 4.3.0's chronological history replay. + * + * The vendor creates weight clusters while a profile beta is unset and promotes a cluster's beta + * after its fourth BIA record. Keeping only the aggregates below is equivalent to retaining the + * full records for chronological live measurements: the original grouping code only reads count, + * average/min/max weight, last weight and last raw resistance. + */ +internal object PicoocAnchorLearner { + private const val MAX_CLUSTER_DISTANCE_GRAMS = 3_000 + private const val SECOND_RECORD_DISTANCE_GRAMS = 4_000 + private const val SECOND_RECORD_MAX_R_DIFF_OHM = 60 + const val REQUIRED_MEASUREMENTS = 4 + + data class Cluster( + val beta: Int, + val count: Int, + val sumWeightGrams: Long, + val minWeightGrams: Int, + val maxWeightGrams: Int, + val lastWeightGrams: Int, + val lastRawOhm: Int, + ) { + val averageWeightGrams: Double get() = sumWeightGrams.toDouble() / count + } + + data class State( + val processedCount: Int = 0, + val clusters: List = emptyList(), + ) { + val progress: Int + get() = clusters.maxOfOrNull { it.count }?.coerceAtMost(REQUIRED_MEASUREMENTS) ?: 0 + } + + /** A beta of zero asks the body-composition implementation to run its native cold-start tree. */ + data class Decision( + val beta: Int, + val clusterIndex: Int?, + ) + + data class Update( + val state: State, + val fixedBeta: Int?, + val progress: Int, + ) + + fun decide(state: State, weightKg: Float, rawOhm: Int): Decision { + val weightGrams = (weightKg * 1_000f).roundToInt() + val groups = state.clusters + if (groups.isEmpty()) return Decision(beta = 0, clusterIndex = null) + + val index = when { + state.processedCount == 1 -> { + val first = groups.first() + val weightDiff = abs(weightGrams - first.lastWeightGrams) + if (weightDiff <= MAX_CLUSTER_DISTANCE_GRAMS || + (weightDiff <= SECOND_RECORD_DISTANCE_GRAMS && + abs(rawOhm - first.lastRawOhm) <= SECOND_RECORD_MAX_R_DIFF_OHM) + ) 0 else null + } + + state.processedCount == 2 && groups.size == 1 -> { + val first = groups.first() + if (weightGrams >= first.minWeightGrams - MAX_CLUSTER_DISTANCE_GRAMS && + weightGrams <= first.maxWeightGrams + MAX_CLUSTER_DISTANCE_GRAMS + ) 0 else null + } + + else -> closestCluster(groups, weightGrams) + } + return if (index == null) Decision(beta = 0, clusterIndex = null) + else Decision(beta = groups[index].beta, clusterIndex = index) + } + + fun accept( + state: State, + decision: Decision, + weightKg: Float, + rawOhm: Int, + calculatedBeta: Int, + ): Update { + // Every entry of the reverse-engineered beta tables is >= 19, so this cannot normally + // happen. Should a table ever be corrected into a smaller value, count the measurement + // as processed and keep it out of the clusters rather than throwing: this runs inside + // the BLE advertisement callback, where an exception costs the whole measurement. + if (calculatedBeta < 19) { + return Update(state = skip(state), fixedBeta = null, progress = state.progress) + } + val weightGrams = (weightKg * 1_000f).roundToInt() + val groups = state.clusters.toMutableList() + val updated: Cluster + if (decision.clusterIndex == null) { + updated = Cluster( + beta = calculatedBeta, + count = 1, + sumWeightGrams = weightGrams.toLong(), + minWeightGrams = weightGrams, + maxWeightGrams = weightGrams, + lastWeightGrams = weightGrams, + lastRawOhm = rawOhm, + ) + groups += updated + } else { + val old = groups[decision.clusterIndex] + updated = old.copy( + count = old.count + 1, + sumWeightGrams = old.sumWeightGrams + weightGrams, + minWeightGrams = minOf(old.minWeightGrams, weightGrams), + maxWeightGrams = maxOf(old.maxWeightGrams, weightGrams), + lastWeightGrams = weightGrams, + lastRawOhm = rawOhm, + ) + groups[decision.clusterIndex] = updated + } + + val next = State(processedCount = state.processedCount + 1, clusters = groups) + return Update( + state = next, + fixedBeta = calculatedBeta.takeIf { updated.count >= REQUIRED_MEASUREMENTS }, + progress = next.progress, + ) + } + + /** Low-resistance measurements are calculated but excluded from beta-cluster learning. */ + fun skip(state: State): State = state.copy(processedCount = state.processedCount + 1) + + fun encode(state: State): String = buildString { + append(state.processedCount) + append('|') + state.clusters.forEachIndexed { index, group -> + if (index > 0) append(';') + append(group.beta).append(',') + append(group.count).append(',') + append(group.sumWeightGrams).append(',') + append(group.minWeightGrams).append(',') + append(group.maxWeightGrams).append(',') + append(group.lastWeightGrams).append(',') + append(group.lastRawOhm) + } + } + + fun decode(value: String?): State { + if (value.isNullOrBlank()) return State() + return runCatching { + val pieces = value.split('|', limit = 2) + val processed = pieces[0].toInt().coerceAtLeast(0) + val groups = pieces.getOrNull(1).orEmpty() + .split(';') + .filter { it.isNotBlank() } + .map { encoded -> + val fields = encoded.split(',') + require(fields.size == 7) + Cluster( + beta = fields[0].toInt(), + count = fields[1].toInt(), + sumWeightGrams = fields[2].toLong(), + minWeightGrams = fields[3].toInt(), + maxWeightGrams = fields[4].toInt(), + lastWeightGrams = fields[5].toInt(), + lastRawOhm = fields[6].toInt(), + ).also { + require(it.beta >= 19 && it.count > 0 && it.sumWeightGrams > 0) + } + } + State(processedCount = maxOf(processed, groups.sumOf { it.count }), clusters = groups) + }.getOrDefault(State()) + } + + private fun closestCluster(groups: List, weightGrams: Int): Int? { + var bestIndex: Int? = null + var bestDistance = Double.POSITIVE_INFINITY + var bestCount = -1 + groups.forEachIndexed { index, group -> + val distance = abs(weightGrams - group.averageWeightGrams) + if (distance > MAX_CLUSTER_DISTANCE_GRAMS) return@forEachIndexed + if (distance < bestDistance || (distance == bestDistance && group.count > bestCount)) { + bestIndex = index + bestDistance = distance + bestCount = group.count + } + } + return bestIndex + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt index cb92b0102..00f598097 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt @@ -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 diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HesleyHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HesleyHandler.kt index f62bed06d..c985bf43d 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HesleyHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HesleyHandler.kt @@ -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 @@ -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) @@ -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 { @@ -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]}") 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 0d9d87142..b6a9a89fd 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 @@ -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 @@ -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) @@ -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) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt index b32ff4bc0..a7a6c5f14 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt @@ -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 @@ -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") } @@ -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 diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OkOkHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OkOkHandler.kt index 210d2baaf..cffe1899c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OkOkHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OkOkHandler.kt @@ -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 @@ -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 @@ -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 } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandler.kt index f6fcb8cad..d21767921 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandler.kt @@ -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 @@ -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") @@ -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 ------------------------------------------------------------------ diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocBroadcastHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocBroadcastHandler.kt new file mode 100644 index 000000000..15328b5f7 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocBroadcastHandler.kt @@ -0,0 +1,445 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.scales + +import android.bluetooth.le.ScanResult +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +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.PicoocAnchorLearner +import com.health.openscale.core.bluetooth.libs.PicoocWhiteBodyComposition +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeIcon +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent +import com.health.openscale.core.service.ScannedDeviceInfo +import java.util.Date +import kotlin.math.roundToInt + +/** + * Parser for the PICOOC Mini Lite broadcast advertisement. + * + * The layout was recovered from PICOOC 4.3.0's `BTBleForBroadcasDevice` and checked against + * three captures from a physical Mini Lite. The full 16-byte manufacturer value is: + * + * [0..5] device MAC, in normal display order + * [6] result state; 0x39 is the completed measurement accepted by the vendor app + * [7] protocol byte (0x1F in the captures; the vendor parser does not inspect it) + * [8..9] weight, unsigned big-endian, 0.05 kg units + * [10..11] impedance, unsigned big-endian, 0.1 ohm units; 0xFFFF means unavailable + * [12..13] secondary weight, unsigned big-endian with bit 15 masked, 0.1 lb units + * [14] display-unit byte (passed through by the vendor app) + * [15] one's-complement checksum of bytes [0..14] + * + * Android treats bytes [0..1] as the little-endian Bluetooth company id. Consequently [parse] + * receives them separately as [manufacturerId], followed by the remaining 14-byte [payload]. + * The id is part of the embedded MAC, not a fixed PICOOC company id, and must never be hardcoded. + */ +internal object PicoocMiniLiteAdv { + const val COMPLETE_STATE = 0x39 + private const val PAYLOAD_SIZE = 14 + private const val MFR_SIZE = 16 + + data class Frame( + val state: Int, + val protocol: Int, + val weightKg: Float, + val impedanceOhm: Float?, + val displayUnit: Int, + ) { + val complete: Boolean get() = state == COMPLETE_STATE + } + + /** Parse one entry exactly as Android exposes it through ManufacturerSpecificData. */ + fun parse(manufacturerId: Int, payload: ByteArray?): Frame? { + if (payload == null || payload.size != PAYLOAD_SIZE) return null + + val mfr = ByteArray(MFR_SIZE) + mfr[0] = manufacturerId.toByte() + mfr[1] = (manufacturerId ushr 8).toByte() + payload.copyInto(mfr, destinationOffset = 2) + + // The vendor checksum is ~(sum(bytes 0..14) & 0xFF). Equivalently, including the + // checksum byte makes the low byte of the total exactly 0xFF. + val checksumTotal = mfr.sumOf { it.toInt() and 0xFF } and 0xFF + if (checksumTotal != 0xFF) return null + + val state = mfr[6].toInt() and 0xFF + val protocol = mfr[7].toInt() and 0xFF + val weightRaw = u16be(mfr, 8) + val impedanceRaw = u16be(mfr, 10) + return Frame( + state = state, + protocol = protocol, + weightKg = weightRaw / 20.0f, + impedanceOhm = if (impedanceRaw == 0xFFFF) null else impedanceRaw / 10.0f, + displayUnit = mfr[14].toInt() and 0xFF, + ) + } + + private fun u16be(data: ByteArray, offset: Int): Int = + ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF) +} + +/** Broadcast-only support for the PICOOC Mini Lite, advertised as `PICOOC-L`. */ +class PicoocBroadcastHandler : ScaleDeviceHandler() { + + companion object { + private const val ADVERTISED_NAME = "PICOOC-L" + private const val WEIGHT_MIN_KG = 0.5f + private const val WEIGHT_MAX_KG = 300.0f + + private const val KEY_ANCHOR_WEIGHT = "anchorWeight" + private const val KEY_ANCHOR_BETA = "anchorBeta" + private const val KEY_LEARNER_STATE = "learnerState" + private const val KEY_PROFILE_FINGERPRINT = "profileFingerprint" + private const val KEY_MEASUREMENT_ANCHOR = "measurementAnchor" + private const val KEY_PREVIOUS_RAW_R = "previousRawR" + private const val KEY_PREVIOUS_CORRECTED_R = "previousCorrectedR" + private const val KEY_PREVIOUS_WEIGHT_GRAMS = "previousWeightGrams" + private const val KEY_PREVIOUS_TIMESTAMP = "previousTimestamp" + private const val KEY_UI_LAST_USER_ID = "ui/lastUserId" + private const val KEY_UI_LAST_USER_NAME = "ui/lastUserName" + private const val KEY_UI_PROGRESS = "ui/progress" + private const val KEY_UI_BETA = "ui/beta" + private const val KEY_UI_ANCHOR_WEIGHT = "ui/anchorWeight" + private const val KEY_UI_FIXED = "ui/fixed" + + // Generic paths on purpose: both quantities are reported by scales of several vendors + // (total muscle = lean mass minus bone, metabolic age), so a user switching brands + // keeps one continuous history instead of a second vendor-bound column. + val TOTAL_MUSCLE = MeasurementType.devicePercent( + "total_muscle", R.string.measurement_type_total_muscle, + icon = MeasurementTypeIcon.IC_M_WORKOUT, color = 0xFF2E7D32.toInt() + ) + val METABOLIC_AGE = MeasurementType.deviceInt( + "metabolic_age", R.string.measurement_type_metabolic_age, + icon = MeasurementTypeIcon.IC_M_TIMER, color = 0xFF795548.toInt() + ) + } + + private val deviceSupport = DeviceSupport( + displayName = "PICOOC Mini Lite", + capabilities = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + ), + implemented = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + ), + linkMode = LinkMode.BROADCAST_ONLY, + ) + + /** The scale repeats its final advertisement; publish it at most once per scan session. */ + private var armed = true + + @Composable + override fun DeviceConfigurationUi() { + var lastUserId by remember { mutableIntStateOf(settingsGetInt(KEY_UI_LAST_USER_ID, -1)) } + var lastUserName by remember { mutableStateOf(settingsGetString(KEY_UI_LAST_USER_NAME).orEmpty()) } + var progress by remember { mutableIntStateOf(settingsGetInt(KEY_UI_PROGRESS, 0)) } + var beta by remember { mutableIntStateOf(settingsGetInt(KEY_UI_BETA, 0)) } + var anchorWeight by remember { mutableIntStateOf(settingsGetInt(KEY_UI_ANCHOR_WEIGHT, 0)) } + var fixed by remember { mutableStateOf(settingsGetInt(KEY_UI_FIXED, 0) == 1) } + + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.picooc_calibration_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + when { + lastUserId < 0 || progress == 0 -> Text(stringResource(R.string.picooc_calibration_no_measurements)) + fixed -> Text( + stringResource( + R.string.picooc_calibration_fixed, + lastUserName, + beta, + anchorWeight, + ) + ) + else -> Text( + stringResource( + R.string.picooc_calibration_learning, + lastUserName, + progress, + beta, + anchorWeight, + ) + ) + } + Button( + onClick = { + resetCalibration(lastUserId) + progress = 0 + beta = 0 + anchorWeight = 0 + fixed = false + }, + enabled = lastUserId >= 0, + ) { + Text( + if (lastUserName.isBlank()) stringResource(R.string.picooc_calibration_reset_generic) + else stringResource(R.string.picooc_calibration_reset, lastUserName) + ) + } + } + } + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? = + deviceSupport.takeIf { device.name.trim().equals(ADVERTISED_NAME, ignoreCase = true) } + + override fun onAdvertisement(result: ScanResult, user: ScaleUser): BroadcastAction { + val manufacturerData = result.scanRecord?.manufacturerSpecificData + ?: return BroadcastAction.IGNORED + + var frame: PicoocMiniLiteAdv.Frame? = null + for (i in 0 until manufacturerData.size()) { + frame = PicoocMiniLiteAdv.parse( + manufacturerData.keyAt(i), + manufacturerData.valueAt(i), + ) + if (frame != null) break + } + val parsed = frame ?: return BroadcastAction.IGNORED + + if (!parsed.complete) { + // Any valid non-final frame belongs to an active measurement and re-arms the guard. + armed = true + logD("measurement in progress (state=0x%02X)".format(parsed.state)) + return BroadcastAction.CONSUMED_KEEP_SCANNING + } + + if (!armed) return BroadcastAction.CONSUMED_STOP + if (parsed.weightKg !in WEIGHT_MIN_KG..WEIGHT_MAX_KG) { + logW("completed frame has implausible weight ${parsed.weightKg} kg; ignoring") + return BroadcastAction.CONSUMED_KEEP_SCANNING + } + + val now = System.currentTimeMillis() + val measurement = ScaleMeasurement().apply { + userId = user.id + dateTime = Date(now) + this[MeasurementType.WEIGHT] = Kg(parsed.weightKg) + parsed.impedanceOhm + ?.takeIf { it > 0.0f } + ?.let { this[MeasurementType.IMPEDANCE] = Ohm(it) } + } + + parsed.impedanceOhm + ?.takeIf { it >= 50f } + ?.let { rawImpedance -> + populateBodyComposition(measurement, user, parsed.weightKg, rawImpedance, now) + } + + armed = false + publish(measurement) + logI( + "measurement published: weight=${parsed.weightKg} kg, " + + "impedance=${parsed.impedanceOhm ?: "unavailable"} ohm, " + + "protocol=0x%02X unit=%d".format(parsed.protocol, parsed.displayUnit) + ) + return BroadcastAction.CONSUMED_STOP + } + + override fun onDisconnected() { + armed = true + } + + private fun populateBodyComposition( + measurement: ScaleMeasurement, + user: ScaleUser, + weightKg: Float, + rawImpedance: Float, + now: Long, + ) { + val age = user.getAge(Date(now)) + if (age < 16 || user.bodyHeight <= 0f) { + logW("body composition requires age >= 16 and a valid height") + return + } + + ensureCalibrationMatchesProfile(user) + + val rawR = rawImpedance.roundToInt() + val previousRawR = settingsGetInt(userKey(KEY_PREVIOUS_RAW_R, user.id), -1).takeIf { it > 0 } + val previousCorrectedR = settingsGetInt(userKey(KEY_PREVIOUS_CORRECTED_R, user.id), -1).takeIf { it > 0 } + val previousWeight = settingsGetInt(userKey(KEY_PREVIOUS_WEIGHT_GRAMS, user.id), -1) + .takeIf { it > 0 } + ?.div(1000f) + val previousTimestamp = settingsGetString(userKey(KEY_PREVIOUS_TIMESTAMP, user.id))?.toLongOrNull() + val correctedR = PicoocWhiteBodyComposition.correctedImpedance( + rawOhm = rawR, + weightKg = weightKg, + timestampMs = now, + previousRawOhm = previousRawR, + previousWeightKg = previousWeight, + previousTimestampMs = previousTimestamp, + previousCorrectedOhm = previousCorrectedR, + ) + + val anchorWeightKey = userKey(KEY_ANCHOR_WEIGHT, user.id) + val anchorBetaKey = userKey(KEY_ANCHOR_BETA, user.id) + val previousAnchorWeight = settingsGetInt(anchorWeightKey, 0).takeIf { it > 0 } + val anchorWeight = PicoocWhiteBodyComposition.anchorWeight(weightKg, previousAnchorWeight) + val fixedBeta = settingsGetInt(anchorBetaKey, 0).takeIf { it >= 19 } + val learnerKey = userKey(KEY_LEARNER_STATE, user.id) + val learnerState = PicoocAnchorLearner.decode(settingsGetString(learnerKey)) + val learnable = fixedBeta == null && correctedR >= 300 + val decision = if (learnable) { + PicoocAnchorLearner.decide(learnerState, weightKg, rawR) + } else { + null + } + val betaForCalculation = fixedBeta ?: decision?.beta ?: 0 + val recentMeasurementAnchor = previousTimestamp + ?.takeIf { now - it in 0L..1_800_000L } + ?.let { settingsGetInt(userKey(KEY_MEASUREMENT_ANCHOR, user.id), 0) } + ?: 0 + + val result = PicoocWhiteBodyComposition.calculate( + PicoocWhiteBodyComposition.Input( + male = user.gender.isMale(), + heightCm = user.bodyHeight, + age = age, + weightKg = weightKg, + correctedImpedanceOhm = correctedR, + anchorWeightKg = anchorWeight, + anchorBeta = betaForCalculation, + hour = java.util.Calendar.getInstance().apply { timeInMillis = now }.get(java.util.Calendar.HOUR_OF_DAY), + previousMeasurementAnchor = recentMeasurementAnchor, + ) + ) ?: return + + val learnerUpdate = when { + fixedBeta != null -> null + decision != null -> PicoocAnchorLearner.accept( + state = learnerState, + decision = decision, + weightKg = weightKg, + rawOhm = rawR, + calculatedBeta = result.anchorBeta, + ) + else -> { + settingsPutString(learnerKey, PicoocAnchorLearner.encode(PicoocAnchorLearner.skip(learnerState))) + null + } + } + learnerUpdate?.let { settingsPutString(learnerKey, PicoocAnchorLearner.encode(it.state)) } + val promotedBeta = learnerUpdate?.fixedBeta + if (promotedBeta != null) settingsPutInt(anchorBetaKey, promotedBeta) + + applyBodyCompositionMeasurements(measurement, result) + + settingsPutInt(anchorWeightKey, anchorWeight) + settingsPutInt(userKey(KEY_MEASUREMENT_ANCHOR, user.id), result.measurementAnchor) + settingsPutInt(userKey(KEY_PREVIOUS_RAW_R, user.id), rawR) + settingsPutInt(userKey(KEY_PREVIOUS_CORRECTED_R, user.id), correctedR) + settingsPutInt(userKey(KEY_PREVIOUS_WEIGHT_GRAMS, user.id), (weightKg * 1000f).roundToInt()) + settingsPutString(userKey(KEY_PREVIOUS_TIMESTAMP, user.id), now.toString()) + + val progress = if (fixedBeta != null || promotedBeta != null) { + PicoocAnchorLearner.REQUIRED_MEASUREMENTS + } else { + learnerUpdate?.progress ?: learnerState.progress + } + settingsPutInt(KEY_UI_LAST_USER_ID, user.id) + settingsPutString(KEY_UI_LAST_USER_NAME, user.userName.ifBlank { user.id.toString() }) + settingsPutInt(KEY_UI_PROGRESS, progress) + settingsPutInt(KEY_UI_BETA, result.anchorBeta) + settingsPutInt(KEY_UI_ANCHOR_WEIGHT, anchorWeight) + settingsPutInt(KEY_UI_FIXED, if (fixedBeta != null || promotedBeta != null) 1 else 0) + + logI( + "PICOOC body composition: fat=${result.bodyFatPercent}, " + + "totalMuscle=${result.totalMusclePercent}, skeletalMuscle=${result.skeletalMusclePercent}, " + + "water=${result.waterPercent}, bone=${result.boneMassKg}, bmr=${result.basalMetabolicRateKcal}, " + + "anchor=$anchorWeight/${result.anchorBeta}/${result.measurementAnchor} " + + "(${if (fixedBeta != null || promotedBeta != null) "fixed" else "learning $progress/4"}), " + + "R=$rawR->$correctedR" + ) + } + + private fun ensureCalibrationMatchesProfile(user: ScaleUser) { + val key = userKey(KEY_PROFILE_FINGERPRINT, user.id) + val fingerprint = "white-v1:${user.gender}:${user.bodyHeight.toBits()}:${user.birthday.time}" + if (settingsGetString(key) == fingerprint) return + + resetCalibration(user.id) + settingsPutString(key, fingerprint) + logI("PICOOC calibration reset because profile inputs changed for user ${user.id}") + } + + private fun resetCalibration(userId: Int) { + settingsPutInt(userKey(KEY_ANCHOR_WEIGHT, userId), 0) + settingsPutInt(userKey(KEY_ANCHOR_BETA, userId), 0) + settingsPutString(userKey(KEY_LEARNER_STATE, userId), "") + settingsPutInt(userKey(KEY_MEASUREMENT_ANCHOR, userId), 0) + settingsPutInt(userKey(KEY_PREVIOUS_RAW_R, userId), 0) + settingsPutInt(userKey(KEY_PREVIOUS_CORRECTED_R, userId), 0) + settingsPutInt(userKey(KEY_PREVIOUS_WEIGHT_GRAMS, userId), 0) + settingsPutString(userKey(KEY_PREVIOUS_TIMESTAMP, userId), "") + if (settingsGetInt(KEY_UI_LAST_USER_ID, -1) == userId) { + settingsPutInt(KEY_UI_PROGRESS, 0) + settingsPutInt(KEY_UI_BETA, 0) + settingsPutInt(KEY_UI_ANCHOR_WEIGHT, 0) + settingsPutInt(KEY_UI_FIXED, 0) + } + } + + /** + * openScale's built-in MUSCLE reference ranges describe skeletal muscle. PICOOC's primary + * "muscle" value is broader (lean mass minus bone) and commonly exceeds openScale's 60% + * plausibility ceiling, so it lands on the separate [TOTAL_MUSCLE] type instead. + */ + internal fun applyBodyCompositionMeasurements( + measurement: ScaleMeasurement, + result: PicoocWhiteBodyComposition.Result, + ) { + measurement[MeasurementType.BODY_FAT] = Percent(result.bodyFatPercent) + measurement[MeasurementType.MUSCLE] = Percent(result.skeletalMusclePercent) + measurement[MeasurementType.WATER] = Percent(result.waterPercent) + measurement[MeasurementType.BONE] = Kg(result.boneMassKg) + measurement[MeasurementType.BMR] = Kcal(result.basalMetabolicRateKcal.toFloat()) + measurement[MeasurementType.PROTEIN] = Percent(result.proteinPercent) + measurement[MeasurementType.VISCERAL_FAT] = result.visceralFatLevel.toFloat() + measurement[MeasurementType.LBM] = Kg(result.leanBodyMassKg) + measurement[TOTAL_MUSCLE] = Percent(result.totalMusclePercent) + result.metabolicAge.takeIf { it in 10..99 }?.let { measurement[METABOLIC_AGE] = it } + } + + private fun userKey(base: String, userId: Int): String = "$base/$userId" +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocHandler.kt index 64e480423..416aee749 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocHandler.kt @@ -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.StandardImpedanceLib @@ -369,10 +370,8 @@ class PicoocHandler : ScaleDeviceHandler() { logI("Picooc body composition unavailable: impedance=$pendingImpedance height=${user.bodyHeight}") } - // openScale has no measurement type for the metabolic body age the 0x32 packet reports, - // nor for the phase angle in the live frames. Re-enabling either needs a - // MeasurementTypeKey, a ScaleMeasurement field, a DB migration and BleConnector wiring. - composition?.bodyAge?.takeIf { it > 0 }?.let { logI("Picooc metabolic body age: $it (not stored)") } + // The phase angle in the live frames still has no measurement type and stays unpublished. + composition?.bodyAge?.takeIf { it in 10..99 }?.let { measurement[METABOLIC_AGE] = it } logI( "Picooc publishing ($reason) → weight=${measurement[MeasurementType.WEIGHT]}kg fat=${measurement[MeasurementType.BODY_FAT]}% " + @@ -442,6 +441,15 @@ class PicoocHandler : ScaleDeviceHandler() { ) companion object { + /** + * Metabolic body age the 0x32 packet reports. 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() + ) + // App→scale frame prefixes. const val PREFIX_LATIN = 0xF1 const val PREFIX_MODERN = 0xA1 diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt index f2b7fbcc7..466ae8d90 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt @@ -18,6 +18,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.Wla25BodyComposition @@ -219,6 +220,15 @@ class RelaxmedicHandler : ScaleDeviceHandler() { this[MeasurementType.PROTEIN] = Percent(result.protein) this[MeasurementType.BMR] = Kcal(result.bmrKcal.toFloat()) this[MeasurementType.LBM] = Kg(result.lbmKg) + Wla25BodyComposition.bodyAge( + age = user.age, + fatPercent = result.fat.toDouble(), + sex = if (user.gender.isMale()) { + Wla25BodyComposition.SEX_MALE + } else { + Wla25BodyComposition.SEX_FEMALE + }, + ).takeIf { it in 10..99 }?.let { this[METABOLIC_AGE] = it } } } else { // The impedances failed the algorithm's validity gate; the weight is @@ -347,6 +357,15 @@ class RelaxmedicHandler : ScaleDeviceHandler() { } companion object { + /** + * Metabolic age from [Wla25BodyComposition.bodyAge]. 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 const val FRAME_SIZE = 20 private const val TYPE_ACK_IN = 0xA0 diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index eda76477e..9ddbfb0c6 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -190,6 +190,14 @@ Extracellular water Intracellular water Protein + Total muscle + Metabolic age + PICOOC learns a stable body-composition anchor from four nearby BIA measurements. + Calibration has not started yet. + %1$s: learning %2$d of 4, preliminary beta %3$d, anchor %4$d kg + %1$s: calibrated, beta %2$d, anchor %3$d kg + Reset calibration for %1$s + Reset PICOOC calibration Body cell mass Added by a scale CSV column diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt index cf2f30eeb..75a0f52cf 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt @@ -59,6 +59,7 @@ import com.health.openscale.core.bluetooth.scales.MiScaleS400Handler 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 @@ -189,6 +190,7 @@ object ScaleCatalog { // The S3 Lite V2.0 advertises as PICOOC-CQ; the Latin series carries no vendor prefix. device("PICOOC-CQ") claimedBy PicoocHandler::class.java, device("Latin-S") claimedBy PicoocHandler::class.java, + device("PICOOC-L") claimedBy PicoocBroadcastHandler::class.java, device("Beurer BF450") claimedBy BeurerBF450Handler::class.java, device("BIA SCALE", SERVICE_FFB0) claimedBy TaylorBIAHandler::class.java, device("RYFIT") claimedBy RyFitHandler::class.java, diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt index bc3b46b5e..ede353bba 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt @@ -29,6 +29,7 @@ import com.health.openscale.core.bluetooth.scales.FitTrackDaraHandler import com.health.openscale.core.bluetooth.scales.HumeDara2Handler import com.health.openscale.core.bluetooth.scales.MGBHandler import com.health.openscale.core.bluetooth.scales.OkOkHandler +import com.health.openscale.core.bluetooth.scales.PicoocBroadcastHandler import com.health.openscale.core.bluetooth.scales.QNHandlerBroadcast import com.health.openscale.core.bluetooth.scales.ScaleupHandler import com.health.openscale.core.bluetooth.scales.SinocareHandler @@ -322,6 +323,28 @@ class ScaleFactoryTest { assertThat(EtekcityFit8SHandler().supportFor(scaleup)).isNull() } + /** PICOOC-L's embedded MAC starts with 0xD0, which also triggers ScaleupHandler's broad match. */ + @Test + fun `PICOOC Mini Lite is matched ahead of the generic Scaleup handler`() { + val picooc = advertisement( + name = "PICOOC-L", + manufacturerData = listOf( + 0x49D0 to byteArrayOf( + 0x00, 0x4B, 0x4F, 0x1E, 0x39, 0x1F, 0x06, 0x76, + 0x13, 0x60, 0x87.toByte(), 0x20, 0x00, 0x40, + ) + ), + ) + + assertClaimedBy(picooc, PicoocBroadcastHandler::class.java) + assertThat(claimants(picooc).map { it.javaClass.simpleName }) + .containsExactly("PicoocBroadcastHandler", "ScaleupHandler").inOrder() + + val order = ScaleFactory.createHandlers().map { it.javaClass.simpleName } + assertThat(order.indexOf("PicoocBroadcastHandler")) + .isLessThan(order.indexOf("ScaleupHandler")) + } + /** * The connectable Etekcity ESF551 is the Fit 8S's closest neighbour — same vendor, so possibly * the same company id. It identifies itself by name and must keep winning: the Fit 8S handler diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/PicoocAnchorLearnerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/PicoocAnchorLearnerTest.kt new file mode 100644 index 000000000..c403517a1 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/PicoocAnchorLearnerTest.kt @@ -0,0 +1,69 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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. + */ +package com.health.openscale.core.bluetooth.libs + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class PicoocAnchorLearnerTest { + + @Test + fun `promotes a nearby weight cluster only on its fourth measurement`() { + var state = PicoocAnchorLearner.State() + val samples = listOf(81.5f to 492, 81.6f to 498, 82.7f to 496, 82.6f to 494) + + samples.forEachIndexed { index, (weight, rawR) -> + val decision = PicoocAnchorLearner.decide(state, weight, rawR) + assertThat(decision.beta).isEqualTo(if (index == 0) 0 else 34) + val update = PicoocAnchorLearner.accept(state, decision, weight, rawR, 34) + assertThat(update.progress).isEqualTo(index + 1) + if (index < 3) assertThat(update.fixedBeta).isNull() + else assertThat(update.fixedBeta).isEqualTo(34) + state = update.state + } + } + + @Test + fun `second record uses the vendor four-kilo and sixty-ohm exception`() { + val first = PicoocAnchorLearner.accept( + PicoocAnchorLearner.State(), + PicoocAnchorLearner.Decision(0, null), + 80f, + 500, + 30, + ).state + + assertThat(PicoocAnchorLearner.decide(first, 83.5f, 560).beta).isEqualTo(30) + assertThat(PicoocAnchorLearner.decide(first, 83.5f, 561).beta).isEqualTo(0) + assertThat(PicoocAnchorLearner.decide(first, 84.1f, 500).beta).isEqualTo(0) + } + + @Test + fun `serialized cluster state round trips and malformed state resets safely`() { + var state = PicoocAnchorLearner.State() + state = PicoocAnchorLearner.accept( + state, + PicoocAnchorLearner.decide(state, 81.5f, 492), + 81.5f, + 492, + 34, + ).state + state = PicoocAnchorLearner.accept( + state, + PicoocAnchorLearner.decide(state, 81.6f, 498), + 81.6f, + 498, + 34, + ).state + + assertThat(PicoocAnchorLearner.decode(PicoocAnchorLearner.encode(state))).isEqualTo(state) + assertThat(PicoocAnchorLearner.decode("broken")).isEqualTo(PicoocAnchorLearner.State()) + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/PicoocWhiteBodyCompositionTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/PicoocWhiteBodyCompositionTest.kt new file mode 100644 index 000000000..fe8168a95 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/PicoocWhiteBodyCompositionTest.kt @@ -0,0 +1,109 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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. + */ +package com.health.openscale.core.bluetooth.libs + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class PicoocWhiteBodyCompositionTest { + + @Test + fun `matches the supplied official PICOOC result`() { + val result = PicoocWhiteBodyComposition.calculate( + PicoocWhiteBodyComposition.Input( + male = true, + heightCm = 176f, + age = 39, + weightKg = 82.7f, + correctedImpedanceOhm = 500, + anchorWeightKg = 81, + anchorBeta = 34, + ) + )!! + + assertThat(result.bodyFatPercent).isWithin(0.05f).of(26.5f) + assertThat(result.totalMusclePercent).isWithin(0.05f).of(69.6f) + assertThat(result.waterPercent).isWithin(0.05f).of(51.2f) + assertThat(result.skeletalMusclePercent).isWithin(0.05f).of(40.4f) + assertThat(result.proteinPercent).isWithin(0.05f).of(18.4f) + assertThat(result.boneMassKg).isWithin(0.05f).of(3.2f) + assertThat(result.basalMetabolicRateKcal).isEqualTo(1683) + assertThat(result.bmi).isWithin(0.05f).of(26.7f) + assertThat(result.visceralFatLevel).isEqualTo(10) + assertThat(result.metabolicAge).isEqualTo(42) + assertThat(result.anchorBeta).isEqualTo(34) + assertThat(result.measurementAnchor).isEqualTo(340) + } + + @Test + fun `rounds new impedance and reuses a sufficiently recent stable correction`() { + val first = PicoocWhiteBodyComposition.correctedImpedance( + rawOhm = 496, + weightKg = 82.7f, + timestampMs = 1_000_000L, + previousRawOhm = null, + previousWeightKg = null, + previousTimestampMs = null, + previousCorrectedOhm = null, + ) + val reused = PicoocWhiteBodyComposition.correctedImpedance( + rawOhm = 492, + weightKg = 82.5f, + timestampMs = 1_300_000L, + previousRawOhm = 496, + previousWeightKg = 82.7f, + previousTimestampMs = 1_000_000L, + previousCorrectedOhm = first, + ) + + assertThat(first).isEqualTo(500) + assertThat(reused).isEqualTo(500) + } + + @Test + fun `keeps and resets the vendor weight anchor at its exact bucket boundaries`() { + assertThat(PicoocWhiteBodyComposition.anchorWeight(82.7f, 81)).isEqualTo(81) + assertThat(PicoocWhiteBodyComposition.anchorWeight(83.0f, 81)).isEqualTo(83) + assertThat(PicoocWhiteBodyComposition.anchorWeight(79.9f, 81)).isEqualTo(79) + } + + @Test + fun `cold start derives profile-neutral anchors from the current measurement`() { + val night = PicoocWhiteBodyComposition.calculate( + PicoocWhiteBodyComposition.Input( + male = true, + heightCm = 176f, + age = 39, + weightKg = 82.7f, + correctedImpedanceOhm = 500, + anchorWeightKg = PicoocWhiteBodyComposition.anchorWeight(82.7f, null), + anchorBeta = 0, + hour = 0, + ) + )!! + val morning = PicoocWhiteBodyComposition.calculate( + PicoocWhiteBodyComposition.Input( + male = true, + heightCm = 176f, + age = 39, + weightKg = 82.7f, + correctedImpedanceOhm = 500, + anchorWeightKg = PicoocWhiteBodyComposition.anchorWeight(82.7f, null), + anchorBeta = 0, + hour = 8, + ) + )!! + + assertThat(night.anchorBeta).isEqualTo(23) + assertThat(night.bodyFatPercent).isWithin(0.05f).of(29.3f) + assertThat(morning.anchorBeta).isEqualTo(24) + assertThat(morning.bodyFatPercent).isWithin(0.05f).of(29.0f) + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/PicoocBroadcastHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/PicoocBroadcastHandlerTest.kt new file mode 100644 index 000000000..5a33e9f50 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/PicoocBroadcastHandlerTest.kt @@ -0,0 +1,185 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.scales + +import android.util.SparseArray +import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.libs.PicoocWhiteBodyComposition +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.service.ScannedDeviceInfo +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Parser regression tests using advertisements captured from a physical PICOOC Mini Lite. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PicoocBroadcastHandlerTest { + + private val packetA = hex("02010609095049434F4F432D4C11FFD049004B4F1E391F065E13388704009C") + private val packetB = hex("02010609095049434F4F432D4C11FFD049004B4F1E391F066013748706005C") + private val packetC = hex("02010609095049434F4F432D4C11FFD049004B4F1E391F0676136087200040") + + private fun hex(value: String): ByteArray = value.removePrefix("0x").chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + + /** Extract the type-0xFF AD value and split it the same way Android's ScanRecord does. */ + private fun manufacturerEntry(advertisement: ByteArray): Pair { + var offset = 0 + while (offset < advertisement.size) { + val length = advertisement[offset].toInt() and 0xFF + require(length > 0 && offset + length < advertisement.size) + val type = advertisement[offset + 1].toInt() and 0xFF + if (type == 0xFF) { + val id = (advertisement[offset + 2].toInt() and 0xFF) or + ((advertisement[offset + 3].toInt() and 0xFF) shl 8) + return id to advertisement.copyOfRange(offset + 4, offset + length + 1) + } + offset += length + 1 + } + error("manufacturer data not found") + } + + private fun parse(packet: ByteArray): PicoocMiniLiteAdv.Frame? { + val (id, payload) = manufacturerEntry(packet) + return PicoocMiniLiteAdv.parse(id, payload) + } + + private fun device(name: String, packet: ByteArray? = packetC): ScannedDeviceInfo { + val manufacturerData = packet?.let { + val (id, payload) = manufacturerEntry(it) + SparseArray().apply { put(id, payload) } + } + return ScannedDeviceInfo( + name = name, + address = "D0:49:00:4B:4F:1E", + rssi = -50, + serviceUuids = emptyList(), + manufacturerData = manufacturerData, + ) + } + + @Test + fun `parses all captured measurements`() { + val a = parse(packetA)!! + val b = parse(packetB)!! + val c = parse(packetC)!! + + assertThat(a.weightKg).isWithin(1e-3f).of(81.5f) + assertThat(a.impedanceOhm).isEqualTo(492.0f) + assertThat(b.weightKg).isWithin(1e-3f).of(81.6f) + assertThat(b.impedanceOhm).isEqualTo(498.0f) + assertThat(c.weightKg).isWithin(1e-3f).of(82.7f) + assertThat(c.impedanceOhm).isEqualTo(496.0f) + } + + @Test + fun `recognises the vendor apps completed-state marker`() { + val frame = parse(packetC)!! + + assertThat(frame.state).isEqualTo(0x39) + assertThat(frame.protocol).isEqualTo(0x1F) + assertThat(frame.complete).isTrue() + assertThat(frame.displayUnit).isEqualTo(0) + } + + @Test + fun `a checksummed non-final state remains in progress`() { + val changed = packetC.copyOf() + changed[21] = 0x38 + changed[30] = checksum(changed.copyOfRange(15, 30)).toByte() + + val frame = parse(changed)!! + assertThat(frame.complete).isFalse() + } + + @Test + fun `rejects an incorrect checksum`() { + val corrupted = packetC.copyOf().also { it[30] = (it[30] + 1).toByte() } + assertThat(parse(corrupted)).isNull() + } + + @Test + fun `rejects a truncated manufacturer record`() { + val (id, payload) = manufacturerEntry(packetC) + assertThat(PicoocMiniLiteAdv.parse(id, payload.copyOf(payload.size - 1))).isNull() + } + + @Test + fun `rejects corrupted weight when the checksum is stale`() { + val corrupted = packetC.copyOf().also { it[24] = 0x77 } + assertThat(parse(corrupted)).isNull() + } + + @Test + fun `does not hardcode the captured manufacturer id or MAC`() { + val changed = packetC.copyOf() + changed[15] = 0x12 + changed[16] = 0x34 + changed[17] = 0x56 + changed[18] = 0x78 + changed[19] = 0x11 + changed[20] = 0x22 + changed[30] = checksum(changed.copyOfRange(15, 30)).toByte() + + assertThat(parse(changed)).isNotNull() + } + + @Test + fun `claims only the exact Mini Lite advertised name`() { + val handler = PicoocBroadcastHandler() + + assertThat(handler.supportFor(device("PICOOC-L"))).isNotNull() + assertThat(handler.supportFor(device("picooc-l"))).isNotNull() + assertThat(handler.supportFor(device("PICOOC-L1"))).isNull() + assertThat(handler.supportFor(device("Scale Up"))).isNull() + assertThat(handler.supportFor(device("", packetC))).isNull() + } + + @Test + fun `saved device snapshot remains identifiable without manufacturer data`() { + assertThat(PicoocBroadcastHandler().supportFor(device("PICOOC-L", packet = null))).isNotNull() + } + + @Test + fun `maps skeletal muscle to openScale muscle and keeps PICOOC total muscle separate`() { + val result = PicoocWhiteBodyComposition.calculate( + PicoocWhiteBodyComposition.Input( + male = true, + heightCm = 176f, + age = 39, + weightKg = 82.7f, + correctedImpedanceOhm = 500, + anchorWeightKg = 81, + anchorBeta = 34, + ) + )!! + val measurement = ScaleMeasurement() + + PicoocBroadcastHandler().applyBodyCompositionMeasurements(measurement, result) + + assertThat(measurement[MeasurementType.MUSCLE]?.value).isWithin(0.05f).of(40.4f) + assertThat(measurement[PicoocBroadcastHandler.TOTAL_MUSCLE]?.value).isWithin(0.05f).of(69.6f) + } + + private fun checksum(bytes: ByteArray): Int = + (bytes.sumOf { it.toInt() and 0xFF } and 0xFF).inv() and 0xFF +} diff --git a/android_app/app/src/test/resources/scale_catalog_remarks.txt b/android_app/app/src/test/resources/scale_catalog_remarks.txt index 8c88a56ac..96054829c 100644 --- a/android_app/app/src/test/resources/scale_catalog_remarks.txt +++ b/android_app/app/src/test/resources/scale_catalog_remarks.txt @@ -18,3 +18,4 @@ Trisa Body Analyze 4.0 = See [Trisa Body Analyze](Trisa-Body-Analyze) for the pr ProfiCare PC-PW 3008 BT = Same Chipsea "WeChat scale" firmware as the Hoffen BBS-8107, so it is driven by the same handler HuaweiHagridWspHandler = The scale only answers after a Huawei Hagrid handshake, so CAK, C1 and C2 (32 hex characters each) have to be entered in the device settings first AiLink Body Fat Scale = Non-connectable: the whole measurement is TEA-encrypted inside the advertisement, so no pairing is needed and the vendor "AiLink" app can be removed. Covers the AiLink/eLink broadcast family (advertises service 0xF0A0, often named "EL1") +PICOOC Mini Lite = Tested with the PICOOC-L broadcast protocol. Weight and impedance are recorded, and the adult Caucasian/White body-composition path is a clean-room port of the PICOOC 4.3.0 ARM64 implementation.