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 a5c446ea7..28db8e5b1 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 @@ -61,6 +61,7 @@ import com.health.openscale.core.bluetooth.scales.QNHandlerBroadcast import com.health.openscale.core.bluetooth.scales.RealmeSmartScaleHandler import com.health.openscale.core.bluetooth.scales.RenphoES26BBHandler import com.health.openscale.core.bluetooth.scales.RenphoHandler +import com.health.openscale.core.bluetooth.scales.RelaxmedicHandler import com.health.openscale.core.bluetooth.scales.RobiS9Handler import com.health.openscale.core.bluetooth.scales.RunstarR5Handler import com.health.openscale.core.bluetooth.scales.RunstarR6Handler @@ -136,6 +137,7 @@ class ScaleFactory @Inject constructor( MiScaleHandler(), RunstarR6Handler(), RunstarR5Handler(), + RelaxmedicHandler(), RobiS9Handler(), VitafitVT701Handler(), EEBBLHandler(), 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 new file mode 100644 index 000000000..cb92b0102 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt @@ -0,0 +1,221 @@ +/* + * openScale + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs + +/** + * Body composition as computed by the Fitdays/icomon `WLA25` algorithm. + * + * Ported from `ICBodyFatAlgorithmWLA25::calc` in the vendor app's + * `libICBodyFatAlgorithms.so`. Fat mass is a 13-term linear regression over + * height, weight, the rounded BMI and all ten impedances; every other field + * follows from fat-free mass. + * + * Verified against the vendor library over 240 randomised inputs spanning both + * clamp boundaries, both impedance branches and both sexes: exact on all ten + * fields. On real hardware it reproduces the scale's display, except bone mass + * which can read 0.1 kg low (see [bone]). + * + * Three details are load-bearing and each is worth a tenth of a unit: + * - the weight is rounded to one decimal *before anything else*, + * - the BMI fed into the regression is rounded, + * - fat mass is rounded before fat-free mass is derived from it. + * + * [round1] is half-up and runs in single precision, as the library's `fmodf` + * chain does. Both matter: `round1(26.35)` is 26.4 where a half-to-even + * rounding gives 26.3, and 1.95 has a float32 fraction of exactly 0.95, so the + * half-up test fails and the answer is 1.9 rather than 2.0. + */ +object Wla25BodyComposition { + + /** Body fat is clamped to this range before anything is derived from it. */ + private const val BFR_MIN = 3.0 + private const val BFR_MAX = 60.0 + + /** Sex as the vendor library encodes it. */ + const val SEX_MALE = 1 + const val SEX_FEMALE = 2 + + data class Result( + val weightKg: Float, + val bmi: Float, + /** Body fat, % of body weight. */ + val fat: Float, + /** Total body water, % of body weight. */ + val water: Float, + /** Muscle, % of body weight. */ + val musclePercent: Float, + val muscleKg: Float, + /** + * Bone mass in kg. + * + * Known limitation: two field measurements read 0.1 kg below the scale's + * own display while the other five fields matched exactly. The vendor app + * agrees with this value, and no algorithm the vendor library ships + * reproduces the scale's combination, so the scale's firmware appears to + * compute bone slightly differently. + */ + val boneKg: Float, + /** Subcutaneous fat, % of body weight. */ + val subcutaneousFat: Float, + /** Visceral fat as a 1..20 level, not a percentage. */ + val visceralFat: Int, + /** Protein, % of body weight. Not cross-checked against the vendor app. */ + val protein: Float, + /** Skeletal muscle, % of body weight. */ + val skeletalMuscle: Float, + val bmrKcal: Int, + /** Fat-free mass in kg. */ + val lbmKg: Float + ) + + /** + * The vendor library's one-decimal rounding: half-up, computed in float32. + * + * The narrowing is deliberate and confined to here. Everything else runs in + * double, as the library does — `dVar49 = dVar39 - dVar38` and friends are + * double subtractions of values that merely *originated* as floats. Widening + * this narrowing to the whole computation shifts BMR by 1 kcal in about one + * case in eighty. + */ + fun round1(value: Double): Double { + val v = value.toFloat() + val whole = v.toInt() + val tenths = (v % 1.0f) * 10.0f + val carried = if (tenths % 1.0f > 0.5f) tenths + 1.0f else tenths + return (carried.toInt() / 10.0f + whole).toDouble() + } + + fun bmi(heightCm: Int, weightKg: Double): Double = + weightKg * 10000.0 / (heightCm * heightCm) + + /** + * The library's own validity gate. + * + * Slots 0 and 5 carry the small leading value of each measurement group + * (~15-25 ohm) and are checked against 1.0; the other eight are ~300 ohm and + * are checked against 100.0. That asymmetry is what pins the ordering of the + * ten values. When the gate fails the library zeroes its entire result + * rather than reporting an error. + */ + fun impedancesValid(imps: DoubleArray): Boolean { + if (imps.size != 10) return false + if (imps[0] < 1.0 || imps[5] < 1.0) return false + for (i in intArrayOf(1, 2, 3, 4, 6, 7, 8, 9)) { + if (imps[i] < 100.0) return false + } + return true + } + + /** Fat mass in kg. [weightKg] and the BMI must already be rounded. */ + private fun fatMass(heightCm: Int, weightKg: Double, imps: DoubleArray): Double { + val scaled0 = imps[0] * 0.826 + // The smaller of the two leading values wins, with a -3.0 offset when + // slot 0 is the smaller one. + val scaled5 = if (imps[5] <= imps[0]) imps[5] * 0.826 else scaled0 - 3.0 + + return weightKg * -0.138 + + heightCm * 0.164 + + round1(bmi(heightCm, weightKg)) * 2.657 + + imps[2] * -0.053 + + imps[1] * -0.000491 + + scaled0 * -0.03 + + imps[4] * -0.127 + + imps[3] * -0.052 + + imps[7] * 0.07 + + imps[6] * 0.019 + + scaled5 * 0.439 + + imps[9] * 0.153 + + imps[8] * 0.07 + + -88.052 + } + + /** + * Compute every field, or `null` if the impedances fail the library's gate. + * + * [rawWeightKg] is the weight straight off the wire; it is rounded here, as + * the device does. + * + * Note there is no sex parameter: this algorithm's body composition does not + * depend on it, only [bodyAge] does. Validation against the vendor library + * passed for both sexes with the formula below, which ignores it. + */ + fun compute(heightCm: Int, rawWeightKg: Double, imps: DoubleArray): Result? { + if (!impedancesValid(imps)) return null + + val weight = round1(rawWeightKg) + val fat = fatMass(heightCm, weight, imps) + val percent = (fat / weight * 100.0).coerceIn(BFR_MIN, BFR_MAX) + + // The clamp bounds the percentage, so recover the fat mass it implies. + val roundedFat = round1(percent / 100.0 * weight) + val ffm = weight - roundedFat + val waterMass = ffm * 0.733 + val musclePercent = round1((ffm * 0.733 + ffm * 0.2) / weight * 100.0) + val bfr = round1(percent) + + // Visceral fat truncates rather than rounds — it is an int cast. + val visceral = (ffm * -0.029 + roundedFat * 0.502 - 0.477).toInt() + .coerceIn(1, 20) + + return Result( + weightKg = weight.toFloat(), + bmi = round1(bmi(heightCm, weight)).toFloat(), + fat = bfr.toFloat(), + water = round1(waterMass / weight * 100.0).toFloat(), + musclePercent = musclePercent.toFloat(), + muscleKg = round1(musclePercent / 100.0 * weight).toFloat(), + boneKg = round1(ffm * 0.067).toFloat(), + subcutaneousFat = round1((bfr * -0.0002 + 0.72) * bfr).toFloat(), + visceralFat = visceral, + protein = round1(ffm * 0.2 / weight * 100.0).toFloat(), + skeletalMuscle = round1((waterMass * 0.834 - 2.627) / weight * 100.0).toFloat(), + bmrKcal = (ffm * 21.6 + 370.0).toInt(), + lbmKg = ffm.toFloat() + ) + } + + /** + * 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. + * + * 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 + * transcription slip; the vendor library really does single it out. + */ + fun bodyAge(age: Int, fatPercent: Double, sex: Int): Int { + if (age < 10) return age + + val bands = if (sex == SEX_MALE) { + arrayOf(14.0 to -3, 19.0 to -2, 24.0 to -1, 27.0 to 1, + 30.0 to 2, 33.0 to 3, 36.0 to 4) + } else { + arrayOf(24.0 to -3, 28.0 to -2, 32.0 to -1, 35.0 to 1, + 38.0 to 2, 42.0 to 3, 45.0 to 4, 46.0 to 0) + } + + for ((upper, delta) in bands) { + if (fatPercent < upper) return age + delta + } + return age + 5 + } +} 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 new file mode 100644 index 000000000..265572dee --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt @@ -0,0 +1,361 @@ +/* + * openScale + * + * 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 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.Wla25BodyComposition +import com.health.openscale.core.service.ScannedDeviceInfo +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.UUID +import kotlin.math.abs + +/** + * Relaxmedic body-composition scale (Fitdays app, icomon protocol version 107). + * + * Shares service 0xFFB0 and the 20-byte frame family with [RobiS9Handler] and + * [RunstarR6Handler], but differs from both in two ways that matter: + * + * - **Frames fragment.** Byte 2 is a fragment index, not a constant zero. The + * 0xA3 result spans two frames and every fragment contributes `bytes[3..18]` + * to the reassembled message — so the byte after the fragment index is + * payload, not a header byte to skip. + * - **The profile is generated, not replayed.** [RobiS9Handler] replays a + * captured handshake because its timestamp and token could not be + * regenerated. The 0xBA profile this scale wants is fully understood, so it + * is built here from the openScale user. + * + * The scale gates its own display on receiving a valid 0xBA profile, and + * **flags1 carries sex and age** — the only place they are sent. Getting that + * byte wrong is silent: the scale accepts the frame and computes for the wrong + * person. + * + * Body composition is computed locally by [Wla25BodyComposition]; the scale + * transmits weight and impedances but never its own derived values. + * + * Frame layout, both directions: + * ``` + * [seq][len][fragment][payload…][checksum] + * ``` + * `len` is the reassembled payload length including the command byte, and the + * trailing byte is `sum(bytes[3..18]) & 0x1F`. Writes with a wrong checksum are + * dropped silently. + */ +class RelaxmedicHandler : ScaleDeviceHandler() { + + private val SERVICE: UUID = uuid16(0xFFB0) + private val CHAR_WRITE: UUID = uuid16(0xFFB1) // write (profile, acks) + private val CHAR_LIVE: UUID = uuid16(0xFFB2) // notify (live A2 weight) + private val CHAR_RESULT: UUID = uuid16(0xFFB3) // indicate (A1 info, A3 result, A0) + + private var outgoingSeq = 0 + private var lastPreviewWeightKg = -1f + private var lastPublishedGrams: Int? = null + + /** Reassembly state for the current multi-fragment message. */ + private var stream = ByteArray(0) + private var expectedLength = 0 + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + val name = device.name.lowercase(Locale.ROOT) + // Other 0xFFB0 families claim by their own names; never take theirs. + if (name.startsWith("swan") || name == "icomon" || name == "yg") return null + if (!name.startsWith("relaxmedic")) return null + + return DeviceSupport( + displayName = "Relaxmedic", + capabilities = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.USER_SYNC + ), + implemented = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.USER_SYNC + ), + linkMode = LinkMode.CONNECT_GATT + ) + } + + override fun onConnected(user: ScaleUser) { + outgoingSeq = 0 + lastPreviewWeightKg = -1f + lastPublishedGrams = null + stream = ByteArray(0) + expectedLength = 0 + + setNotifyOn(SERVICE, CHAR_LIVE) + setNotifyOn(SERVICE, CHAR_RESULT) + + sendUserProfile(user) + userInfo(R.string.bt_info_step_on_scale) + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + if (data.size != FRAME_SIZE) return + if (!isChecksumValid(data)) { + logD("Relaxmedic checksum mismatch, dropping frame") + return + } + + val message = reassemble(data) ?: return + val command = message[0].toInt() and 0xFF + val payload = message.copyOfRange(1, message.size) + + when (command) { + TYPE_LIVE_WEIGHT -> handleLiveWeight(payload) + TYPE_FINAL_RESULT -> handleFinalResult(payload, data[0].toInt() and 0xFF, user) + TYPE_DEVICE_INFO -> logD("Relaxmedic device info, ${payload.size} byte payload") + TYPE_ACK_IN -> sendAck(data[0].toInt() and 0xFF) + else -> logD("Relaxmedic unhandled command 0x${"%02X".format(command)}") + } + } + + /** + * Accumulate fragments until a whole message is present. + * + * Every fragment contributes `bytes[3..18]`; on fragment 0 the first of + * those is the command byte, so a complete message is `len + 1` bytes. + */ + private fun reassemble(frame: ByteArray): ByteArray? { + val length = frame[1].toInt() and 0xFF + val fragment = frame[2].toInt() and 0xFF + val chunk = frame.copyOfRange(3, 19) + + if (fragment == 0) { + stream = chunk + expectedLength = length + } else { + if (stream.isEmpty()) return null // continuation without a start + stream += chunk + } + + if (stream.size < expectedLength + 1) return null + val message = stream.copyOfRange(0, expectedLength + 1) + stream = ByteArray(0) + expectedLength = 0 + return message + } + + /** + * 0xA2 live weight. Payload: `[state][packed u32 BE][heart rate][mode]`. + * Never published — only the 0xA3 result is authoritative. + */ + private fun handleLiveWeight(payload: ByteArray) { + if (payload.size < 5) return + val weightKg = (u32be(payload, 1) and WEIGHT_MASK) / 1000.0f + if (abs(weightKg - lastPreviewWeightKg) >= 0.05f) { + userInfo(R.string.bluetooth_scale_info_measuring_weight, weightKg) + lastPreviewWeightKg = weightKg + } + } + + /** + * 0xA3 settled reading. + * + * Payload: a big-endian `u32` whose low 18 bits are grams, then two unused + * bytes, then the impedances as big-endian `u16` from offset 5, each in + * tenths of an ohm. They run to the end of the payload — ten of them on this + * scale, in two groups of five, each group beginning with a small value. + * + * The scale repeats this frame several times; only the first is published. + */ + private fun handleFinalResult(payload: ByteArray, seq: Int, user: ScaleUser) { + if (payload.size < 5) return + + val grams = u32be(payload, 0) and WEIGHT_MASK + if (lastPublishedGrams == grams) { + sendAck(seq) + return + } + + val imps = DoubleArray((payload.size - 5) / 2) { i -> + u16be(payload, 5 + 2 * i) / 10.0 + } + + val measurement = ScaleMeasurement().apply { + dateTime = Date() + weight = grams / 1000.0f + if (imps.isNotEmpty()) impedance = imps[0] + } + + val result = Wla25BodyComposition.compute( + heightCm = user.bodyHeight.toInt(), + rawWeightKg = measurement.weight.toDouble(), + imps = imps + ) + + if (result != null) { + measurement.apply { + weight = result.weightKg + fat = result.fat + water = result.water + muscle = result.musclePercent + bone = result.boneKg + visceralFat = result.visceralFat.toFloat() + protein = result.protein + bmr = result.bmrKcal.toFloat() + lbm = result.lbmKg + } + } else { + // The impedances failed the algorithm's validity gate; the weight is + // still good, so publish that rather than nothing. + logW("Relaxmedic impedances rejected (${imps.size} values), weight only") + } + + publish(measurement) + lastPublishedGrams = grams + sendAck(seq) + } + + /** + * 0xBA user profile. + * + * ``` + * u8 0xBA + * u32 unix time, big-endian + * u16 UTC offset in minutes; bit 0x8000 marks a negative offset + * u32 user id + * u8 height in cm + * u16 profile weight * 100 + * u8 flags1 = (sex shl 7) or (age and 0x7F) + * u8 flags2 feature bits + * u8 trailing + * ``` + * + * `flags2` and `trailing` are the values the vendor app sends and the scale + * accepts. Their bit assignments are only partly understood, so they are + * kept verbatim rather than derived. + */ + private fun sendUserProfile(user: ScaleUser) { + val heightCm = user.bodyHeight.toInt() + val age = user.age + val sexBit = if (user.gender.isMale()) 1 else 0 + val flags1 = (sexBit shl 7) or (age and 0x7F) + + val nowSeconds = System.currentTimeMillis() / 1000L + val offsetMinutes = TimeZone.getDefault() + .getOffset(System.currentTimeMillis()) / 60000 + val encodedOffset = (abs(offsetMinutes) and 0x7FFF) + .let { if (offsetMinutes < 0) it or 0x8000 else it } + + // The profile's stored weight, not a live reading; the scale only needs + // it to be plausible. + val profileWeight = (user.initialWeight.takeIf { it > 0f } ?: 60.0f) + val weightHundredths = (profileWeight * 100.0f).toInt() + + val payload = ByteArray(15) + payload[0] = TYPE_USER_PROFILE.toByte() + putU32be(payload, 1, nowSeconds) + putU16be(payload, 5, encodedOffset) + putU32be(payload, 7, 0L) // user id + payload[11] = (heightCm and 0xFF).toByte() + putU16be(payload, 12, weightHundredths) + payload[14] = (flags1 and 0xFF).toByte() + + // flags2 and trailing continue past the 15 bytes carried here; the frame + // builder zero-pads, so append them explicitly. + val full = payload + byteArrayOf(FLAGS2.toByte(), TRAILING.toByte()) + writeTo(SERVICE, CHAR_WRITE, buildFrame(full), withResponse = true) + logD("Relaxmedic profile sent: ${heightCm}cm, age $age, sex $sexBit") + } + + /** 0xB0 — acknowledge a packet the scale sent. */ + private fun sendAck(seq: Int) { + writeTo( + SERVICE, CHAR_WRITE, + buildFrame(byteArrayOf(TYPE_ACK_OUT.toByte(), (seq and 0xFF).toByte(), 0x00)), + withResponse = true + ) + } + + /** + * Wrap a message (command byte first) in a 20-byte frame. + * + * `len` counts the payload after the command byte. Only single-fragment + * writes are needed — everything this handler sends fits. + */ + private fun buildFrame(message: ByteArray): ByteArray { + val frame = ByteArray(FRAME_SIZE) + frame[0] = (outgoingSeq and 0xFF).toByte() + outgoingSeq = (outgoingSeq + 1) and 0xFF + frame[1] = ((message.size - 1) and 0xFF).toByte() + frame[2] = 0x00 + // A frame carries 16 payload bytes. The 0xBA profile is one byte longer, + // and the vendor app simply lets the trailing byte fall off -- the + // captured frame the scale accepts ends `... 95 2f 04`, with `len` still + // counting the untruncated length. Reproduced rather than corrected, + // because this is the form known to work on hardware. + val carried = minOf(message.size, FRAME_SIZE - 4) + message.copyInto(frame, 3, 0, carried) + frame[19] = computeChecksum(frame).toByte() + return frame + } + + /** `sum(bytes[3..18]) & 0x1F`. Writes failing it are dropped silently. */ + private fun computeChecksum(frame: ByteArray): Int { + var sum = 0 + for (i in 3..18) sum += frame[i].toInt() and 0xFF + return sum and 0x1F + } + + private fun isChecksumValid(frame: ByteArray): Boolean = + (frame[19].toInt() and 0xFF) == computeChecksum(frame) + + private fun u16be(data: ByteArray, offset: Int): Int = + ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF) + + private fun u32be(data: ByteArray, offset: Int): Int = + ((data[offset].toInt() and 0xFF) shl 24) or + ((data[offset + 1].toInt() and 0xFF) shl 16) or + ((data[offset + 2].toInt() and 0xFF) shl 8) or + (data[offset + 3].toInt() and 0xFF) + + private fun putU16be(data: ByteArray, offset: Int, value: Int) { + data[offset] = ((value shr 8) and 0xFF).toByte() + data[offset + 1] = (value and 0xFF).toByte() + } + + private fun putU32be(data: ByteArray, offset: Int, value: Long) { + data[offset] = ((value shr 24) and 0xFF).toByte() + data[offset + 1] = ((value shr 16) and 0xFF).toByte() + data[offset + 2] = ((value shr 8) and 0xFF).toByte() + data[offset + 3] = (value and 0xFF).toByte() + } + + companion object { + private const val FRAME_SIZE = 20 + + private const val TYPE_ACK_IN = 0xA0 + private const val TYPE_DEVICE_INFO = 0xA1 + private const val TYPE_LIVE_WEIGHT = 0xA2 + private const val TYPE_FINAL_RESULT = 0xA3 + private const val TYPE_ACK_OUT = 0xB0 + private const val TYPE_USER_PROFILE = 0xBA + + /** The packed weight word carries grams in its low 18 bits. */ + private const val WEIGHT_MASK = 0x3FFFF + + /** Feature bits and trailer, verbatim from a capture the scale accepted. */ + private const val FLAGS2 = 0x2F + private const val TRAILING = 0x0F + } +}