From 7f35a1b03ce4b36d9510500035741bda747163e1 Mon Sep 17 00:00:00 2001 From: andyjk15 Date: Mon, 24 Aug 2026 18:15:23 +0100 Subject: [PATCH 1/2] Add support for the Hume Health Dara 2.0 scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1448. This device advertises literally as "Dara 2.0" and its BLE Device Information service reports manufacturer "LeFu Scale" — not FitTrack, despite sharing a model name with FitTrack's unrelated "Dara" scale (a different product on a different chip family, 0xFFB0/AC02 framing vs this device's 0xFFF0/0xCF framing). That name collision is why FitTrackDaraHandler's "FITTRACK"-prefixed name match never fired for this unit, even after 3.1.2 shipped named FitTrack Dara 2.0 support. New HumeDara2Handler matches on the device's actual advertised name and decodes the real 11-byte measurement frame (reverse-engineered from five real weigh-ins, ground truth documented in the class doc comment): weight and a raw BIA impedance reading, both cross-checked against the official Hume app's own display. Body composition (fat/water/muscle/ bone/BMR) is computed from that impedance via openScale's existing StandardImpedanceLib generic formula rather than guessed frame offsets, since the remaining frame bytes don't correlate with anything Hume's own app displays — this is an approximation of Hume's proprietary numbers, not a device reading, and the class doc comment states the measured accuracy against a real reading (~1-2% off on skeletal muscle %/BMR, ~15% on fat%/water%/lean mass, ~24% on bone). Kept as a sibling of ExcelvanCF36xHandler (same GATT layout and 0xCF frame header, evidently the same underlying chip family under a different rebrand) rather than merged into it, since the measurement frame shape differs and the device names don't collide — matching this codebase's existing one-handler-per-rebrand pattern. --- .../openscale/core/bluetooth/ScaleFactory.kt | 5 + .../core/bluetooth/scales/HumeDara2Handler.kt | 256 ++++++++++++++++++ .../openscale/core/bluetooth/ScaleCatalog.kt | 2 + .../core/bluetooth/ScaleFactoryTest.kt | 23 ++ .../bluetooth/scales/HumeDara2HandlerTest.kt | 89 ++++++ 5 files changed, 375 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HumeDara2Handler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HumeDara2HandlerTest.kt 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 427e30305..aa246bdde 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 @@ -46,6 +46,7 @@ import com.health.openscale.core.bluetooth.scales.HoffenBbs8107Handler import com.health.openscale.core.bluetooth.scales.HuaweiAhCh100Handler import com.health.openscale.core.bluetooth.scales.HuaweiCH100SHandler import com.health.openscale.core.bluetooth.scales.HuaweiHagridWspHandler +import com.health.openscale.core.bluetooth.scales.HumeDara2Handler import com.health.openscale.core.bluetooth.scales.IHealthHS3Handler import com.health.openscale.core.bluetooth.scales.InlifeHandler import com.health.openscale.core.bluetooth.scales.KeepS3Handler @@ -175,6 +176,10 @@ class ScaleFactory @Inject constructor( HesleyHandler(), ExingtechY1Handler(), EbelterBodyFatB2Handler(), + // Same LeFu/0xFFF0 chip family; kept as siblings, not merged, since neither name + // ("Dara 2.0" / "Electronic Scale") collides with the other — see HumeDara2Handler's + // class doc for why it isn't FitTrackDaraHandler's differently-branded "Dara" either. + HumeDara2Handler(), ExcelvanCF36xHandler(), EtekcityESF551Handler(), EtekcityFit8SHandler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HumeDara2Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HumeDara2Handler.kt new file mode 100644 index 000000000..dc896b4f8 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HumeDara2Handler.kt @@ -0,0 +1,256 @@ +/* + * 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 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.StandardImpedanceLib +import com.health.openscale.core.data.ActivityLevel +import com.health.openscale.core.data.WeightUnit +import com.health.openscale.core.service.ScannedDeviceInfo +import com.health.openscale.core.utils.ConverterUtils +import java.util.UUID +import kotlin.math.roundToInt + +/** + * Hume Health "Dara 2.0" body-composition scale. + * + * NOT [FitTrackDaraHandler]: FitTrack sells an unrelated scale that is *also* marketed as + * "Dara" and advertises with a "FITTRACK" name prefix on service 0xFFB0 with `AC 02 …` + * framing. This device is a completely different product from a different vendor that + * happens to share the "Dara 2.0" model name — it advertises literally as `"Dara 2.0"` + * with no FitTrack prefix, and its own standard BLE Device Information service reports the + * manufacturer as **"LeFu Scale"**, not FitTrack. That mismatch between the marketing name + * ("Dara 2.0", suggesting FitTrack support already covers it) and the actual OEM hardware is + * why this device showed "Not Supported" even after FitTrack Dara support was added in + * 3.1.2 — see https://github.com/oliexdev/openScale/issues/1448. + * + * ## GATT layout + * Confirmed via three separate Debug-mode connections and reproduced across five real + * weigh-ins with the production handler below: + * Service 0xFFF0: + * 0xFFF1 – user config WRITE (not notifiable) + * 0xFFF4 – measurement NOTIFY + * + * This is the same GATT shape [ExcelvanCF36xHandler] uses for "Electronic Scale"-branded + * units (also 0xFFF0/0xFFF1/0xFFF4, same 0xCF frame header and the same 8-byte user-config + * write) — evidently the same underlying LeFu/Excelvan chip family under a different + * storefront rebrand. It is kept as a **separate handler** rather than folded into + * [ExcelvanCF36xHandler] because the measurement frame is a different, shorter shape (see + * below) and the device name ("Dara 2.0") doesn't collide with "Electronic Scale" — matching + * this codebase's existing pattern of one handler per exact rebrand name even within a + * shared chip family (see ActiveEraBF06Handler, KeepS3Handler, etc. registered ahead of the + * generic LeFu/0xFFF0 fallback in ScaleFactory). + * + * ## Frame format (11 bytes, reverse-engineered from five real weigh-ins) + * `[0]=0xCF [1..2]=impedance (BE) ÷100 [ohms] [3..4]=weight (LE) ÷100 [kg] + * [5] [6] [7] [8] [9]=unexplained [10]=XOR checksum over bytes[0..9]` + * + * While the reading settles, the scale streams live weight-only frames with bytes[1],[2], + * [5],[6],[7] all zero; once locked it sends a final frame with those populated, repeated + * twice, then disconnects itself after a short idle period. [isLockedFrame] detects that + * transition; only the locked frame is published (dropping the live stream matches + * [ExcelvanCF36xHandler]'s existing single-final-frame behaviour for the same chip family). + * + * Ground truth, all five real weigh-ins (same person, same day, same 188cm/18yr profile): + * `CF 0A 14 DE 21 5A 55 6F 01 00 4F` → 86.70 kg, impedance 25.80Ω (Hume app: 86.68 kg) — + * this first capture's impedance is implausibly low (bad/rushed foot contact); excluded + * from the body-composition range below, see [PLAUSIBLE_IMPEDANCE_OHMS]. + * `CF C4 13 E8 21 E5 E7 96 00 00 45` → 86.80 kg, impedance 501.95Ω (Hume: 86.9 kg) + * `CF C4 13 ED 21 AC A6 AE 00 00 70` → 86.85 kg, impedance 501.95Ω (Hume: 86.85 kg, exact) + * `CF C4 13 F7 21 5F 55 75 00 00 B1` → 86.95 kg, impedance 501.95Ω (Hume: 86.9-87.0 kg) + * `CF B0 13 F2 21 70 75 B7 00 00 0D` → 86.90 kg, impedance 450.75Ω (Hume: 86.9 kg, exact) + * Weight matches Hume's own reading within display rounding on every capture. The checksum + * formula (XOR of all ten preceding bytes) was verified against all five frames. + * + * ## Body composition: [StandardImpedanceLib], not Hume's own numbers + * Bytes[5..9] don't correlate with anything Hume's app displays — across the captures above, + * bytes[1..2] (the impedance field) stayed effectively flat across three consecutive + * same-session readings (501.95Ω) while Hume's displayed fat%/water%/muscle stayed frozen + * too, but bytes[5..9] moved anyway with no matching change on screen. There isn't room in an + * 11-byte frame for the ~13 distinct metrics Hume's app shows either, which strongly suggests + * most of what's on screen is *computed client-side* by Hume from impedance + user profile, + * not transmitted raw. + * + * So rather than guess at bytes[5..9], this handler feeds the one number we *did* verify + * (impedance, landing at 450-502Ω across genuine readings — squarely in the normal + * foot-to-foot BIA range) through openScale's own [StandardImpedanceLib] (generic published + * BIA formulas, not Hume's proprietary ones). Checked against a real reading (450.75Ω, 86.9kg, + * this device's user profile): skeletal muscle % and BMR land within ~1-2% of Hume's own display, + * fat%/water%/lean mass within ~15% (the errors run in complementary directions — fat low, + * water/lean high — which is internally consistent, not random noise), bone ~24% off. That's + * an approximation, not a device reading, which is why [DeviceSupport.implemented] still + * claims [DeviceCapability.BODY_COMPOSITION] but every value below traces back to + * [StandardImpedanceLib] rather than a frame offset. Subcutaneous fat, visceral fat index, + * and a "skeletal mass" distinct from skeletal muscle mass are not attempted at all: those + * need segmental/multi-frequency BIA hardware this single whole-body impedance reading can't + * provide, and [ScaleMeasurement] has no field for the last one regardless. + */ +class HumeDara2Handler : ScaleDeviceHandler() { + + companion object { + /** Only trust the impedance-derived body-comp formula in the plausible foot-to-foot + * BIA range — see [StandardImpedanceLib]'s own doc comment (500 ± 100Ω for a ~180cm + * adult of normal BMI). The very first unit we captured read ~26Ω on a rushed/bad + * first contact and would have produced nonsense fat%/water% if fed through anyway. */ + private val PLAUSIBLE_IMPEDANCE_OHMS = 300.0..900.0 + + /** Little-endian weight ÷100 at bytes[3..4], in kg. */ + fun weightKgFromFrame(frame: ByteArray): Float = + ConverterUtils.fromUnsignedInt16Le(frame, 3) / 100.0f + + /** Big-endian impedance ÷100 at bytes[1..2], in ohms. */ + fun impedanceOhmsFromFrame(frame: ByteArray): Double = + ConverterUtils.fromUnsignedInt16Be(frame, 1) / 100.0 + + /** XOR of bytes[0..9]; must equal byte[10]. */ + fun checksum(frame: ByteArray): Int { + var sum = 0 + for (i in 0..9) sum = sum xor (frame[i].toInt() and 0xFF) + return sum and 0xFF + } + + /** True once the scale has locked its reading (bytes[1],[2],[5],[6],[7] populate). */ + fun isLockedFrame(frame: ByteArray): Boolean = + (frame[1].toInt() or frame[2].toInt() or frame[5].toInt() or + frame[6].toInt() or frame[7].toInt()) != 0 + } + + private val SVC get() = uuid16(0xFFF0) + private val CHAR_WRITE get() = uuid16(0xFFF1) + private val CHAR_NOTIFY get() = uuid16(0xFFF4) + + /** Last locked frame we published, to ignore the repeat the scale always sends. */ + private var lastFrame: ByteArray? = null + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + if (!device.name.equals("Dara 2.0", ignoreCase = true)) return null + + return DeviceSupport( + displayName = "Hume Health Dara 2.0", + capabilities = setOf( + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.USER_SYNC, + DeviceCapability.UNIT_CONFIG + ), + // Body composition is StandardImpedanceLib's estimate, not a device reading — see + // the class doc comment for the measured accuracy against Hume's own display. + implemented = setOf( + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.USER_SYNC, + DeviceCapability.UNIT_CONFIG + ), + linkMode = LinkMode.CONNECT_GATT + ) + } + + override fun onConnected(user: ScaleUser) { + lastFrame = null + + // Same 8-byte user-config write as ExcelvanCF36xHandler — same chip family. + val cfg = buildUserConfig(user) + writeTo(SVC, CHAR_WRITE, cfg, withResponse = true) + + setNotifyOn(SVC, CHAR_NOTIFY) + + userInfo(R.string.bt_info_step_on_scale) + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + if (characteristic != CHAR_NOTIFY) return + if (data.size != 11 || data[0] != 0xCF.toByte()) { + logD("unexpected frame len=${data.size} head=${if (data.isNotEmpty()) String.format("%02X", data[0]) else "-"}") + return + } + + val expectedChecksum = checksum(data) + if ((data[10].toInt() and 0xFF) != expectedChecksum) { + logD("checksum mismatch: got=${String.format("%02X", data[10])} want=${String.format("%02X", expectedChecksum)}") + return + } + + if (!isLockedFrame(data)) return // live weight stream while the scale settles + + val previous = lastFrame + if (previous != null && previous.contentEquals(data)) return // the locked frame repeats once + lastFrame = data.copyOf() + + publishFrame(data, user) + requestDisconnect() + } + + private fun publishFrame(frame: ByteArray, user: ScaleUser) { + val weightKg = ConverterUtils.toKilogram(weightKgFromFrame(frame), user.scaleUnit) + val impedanceOhms = impedanceOhmsFromFrame(frame) + + val m = ScaleMeasurement().apply { weight = weightKg } + + if (impedanceOhms in PLAUSIBLE_IMPEDANCE_OHMS && user.bodyHeight > 0f) { + val bia = StandardImpedanceLib( + gender = user.gender, + age = user.age, + weightKg = weightKg.toDouble(), + heightM = user.bodyHeight / 100.0, + impedance = impedanceOhms + ) + m.impedance = impedanceOhms + m.fat = bia.totalFatPercentage.toFloat() + m.water = bia.totalBodyWaterPercentage.toFloat() + m.muscle = bia.skeletalMusclePercentage.toFloat() + m.bone = bia.boneMassKg.toFloat() + m.bmr = bia.basalMetabolicRate.toFloat() + m.lbm = bia.fatFreeMassKg.toFloat() + logD("publish kg=${m.weight} impedance=$impedanceOhms fat=${m.fat} water=${m.water} muscle=${m.muscle} bone=${m.bone} bmr=${m.bmr} lbm=${m.lbm}") + } else { + logD("publish kg=${m.weight} impedance=$impedanceOhms out of plausible range, skipping body composition") + } + + publish(m) + } + + /** Identical 8-byte config write to [ExcelvanCF36xHandler.buildUserConfig] — same chip family. */ + private fun buildUserConfig(user: ScaleUser): ByteArray { + val sex = if (user.gender.isMale()) 0x01 else 0x00 + val activity = when (user.activityLevel) { + ActivityLevel.SEDENTARY, + ActivityLevel.MILD -> 0x00 + ActivityLevel.MODERATE -> 0x01 + ActivityLevel.HEAVY, + ActivityLevel.EXTREME -> 0x02 + } + val height = user.bodyHeight.roundToInt().coerceIn(0, 255) + val age = user.age.coerceIn(0, 255) + val unit = when (user.scaleUnit) { + WeightUnit.KG -> 0x01 + WeightUnit.LB -> 0x02 + WeightUnit.ST -> 0x04 + } + + val cfg = byteArrayOf( + 0xFE.toByte(), 0x01, sex.toByte(), activity.toByte(), + height.toByte(), age.toByte(), unit.toByte(), 0x00 + ) + var xor = 0 + for (i in 1..6) xor = xor xor (cfg[i].toInt() and 0xFF) + cfg[7] = xor.toByte() + return cfg + } +} 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 692ad7757..f2329c292 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 @@ -43,6 +43,7 @@ import com.health.openscale.core.bluetooth.scales.HoffenBbs8107Handler import com.health.openscale.core.bluetooth.scales.HuaweiAhCh100Handler import com.health.openscale.core.bluetooth.scales.HuaweiCH100SHandler import com.health.openscale.core.bluetooth.scales.HuaweiHagridWspHandler +import com.health.openscale.core.bluetooth.scales.HumeDara2Handler import com.health.openscale.core.bluetooth.scales.IHealthHS3Handler import com.health.openscale.core.bluetooth.scales.InlifeHandler import com.health.openscale.core.bluetooth.scales.KeepS3Handler @@ -214,6 +215,7 @@ object ScaleCatalog { device("vscale") claimedBy ExingtechY1Handler::class.java, device("Body Fat-B2") claimedBy EbelterBodyFatB2Handler::class.java, device("Electronic Scale") claimedBy ExcelvanCF36xHandler::class.java, + device("Dara 2.0") claimedBy HumeDara2Handler::class.java, device("Etekcity Smart Fitness Scale") claimedBy EtekcityESF551Handler::class.java, device("EUFY C20") claimedBy EufyC20Handler::class.java, device("eufy T9148") claimedBy EufyP2Handler::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 a4229e22e..bc3b46b5e 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 @@ -24,7 +24,9 @@ import com.health.openscale.core.bluetooth.scales.DrTrustSSW532Handler import com.health.openscale.core.bluetooth.scales.EtekcityESF551Handler import com.health.openscale.core.bluetooth.scales.EtekcityFit8SHandler import com.health.openscale.core.bluetooth.scales.EufyC20Handler +import com.health.openscale.core.bluetooth.scales.ExcelvanCF36xHandler 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.QNHandlerBroadcast @@ -206,6 +208,27 @@ class ScaleFactoryTest { assertClaimedBy(device("yg", SERVICE_FFB0), MGBHandler::class.java) } + /** + * "Dara 2.0" (Hume Health, OEM "LeFu Scale", 0xFFF0/0xCF framing) and "FitTrack Dara" (an + * unrelated FitTrack product, 0xFFB0/0xAC02 framing) are two different scales that happen + * to share a model name. FitTrackDaraHandler only matches names starting with "FITTRACK", + * so it never takes this device — but pin that explicitly since it's exactly the confusion + * https://github.com/oliexdev/openScale/issues/1448 reported ("Not Supported" despite named + * FitTrack Dara 2.0 support already existing). Also pin the sibling boundary against + * ExcelvanCF36xHandler, the other 0xFFF0 handler: neither name collides with the other, but + * both are on the same chip family in the same part of the registry. + */ + @Test + fun `Hume Dara 2_0 is not swallowed by the unrelated FitTrack Dara handler`() { + assertClaimedBy(device("Dara 2.0"), HumeDara2Handler::class.java) + assertClaimedBy(device("FITTRACK Dara", SERVICE_FFB0), FitTrackDaraHandler::class.java) + assertClaimedBy(device("Electronic Scale"), ExcelvanCF36xHandler::class.java) + + assertThat(FitTrackDaraHandler().supportFor(device("Dara 2.0"))).isNull() + assertThat(ExcelvanCF36xHandler().supportFor(device("Dara 2.0"))).isNull() + assertThat(HumeDara2Handler().supportFor(device("Electronic Scale"))).isNull() + } + /** * SanitasSbf72Handler and BeurerSanitasHandler both answer to `sbf7x` names; only the list * position keeps the SBF72/73 devices on the driver written for them. diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HumeDara2HandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HumeDara2HandlerTest.kt new file mode 100644 index 000000000..fa7ea7173 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HumeDara2HandlerTest.kt @@ -0,0 +1,89 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for the Hume Health Dara 2.0 frame decode ([HumeDara2Handler.weightKgFromFrame] / + * [HumeDara2Handler.impedanceOhmsFromFrame] / [HumeDara2Handler.checksum] / + * [HumeDara2Handler.isLockedFrame]), reverse-engineered from five real weigh-ins on the same + * person/device/profile — see [HumeDara2Handler]'s class doc for the full ground truth table + * and how each was cross-checked against Hume's own on-screen reading. + */ +class HumeDara2HandlerTest { + + private fun bytes(vararg v: Int): ByteArray = ByteArray(v.size) { v[it].toByte() } + + // --- Real captures -------------------------------------------------------------------- + + private val lockedFrame1 = bytes(0xCF, 0x0A, 0x14, 0xDE, 0x21, 0x5A, 0x55, 0x6F, 0x01, 0x00, 0x4F) + private val lockedFrame2 = bytes(0xCF, 0xC4, 0x13, 0xE8, 0x21, 0xE5, 0xE7, 0x96, 0x00, 0x00, 0x45) + private val lockedFrame3 = bytes(0xCF, 0xC4, 0x13, 0xED, 0x21, 0xAC, 0xA6, 0xAE, 0x00, 0x00, 0x70) + private val lockedFrame4 = bytes(0xCF, 0xC4, 0x13, 0xF7, 0x21, 0x5F, 0x55, 0x75, 0x00, 0x00, 0xB1) + private val lockedFrame5 = bytes(0xCF, 0xB0, 0x13, 0xF2, 0x21, 0x70, 0x75, 0xB7, 0x00, 0x00, 0x0D) + + // A live (not yet locked) frame from mid-settle on the same weigh-in as lockedFrame5. + private val liveFrame = bytes(0xCF, 0x00, 0x00, 0xDE, 0x21, 0x00, 0x00, 0x00, 0x00, 0x01, 0x31) + + @Test + fun `weight decodes to the recorded kg for every real capture`() { + assertThat(HumeDara2Handler.weightKgFromFrame(lockedFrame1)).isWithin(1e-3f).of(86.70f) + assertThat(HumeDara2Handler.weightKgFromFrame(lockedFrame2)).isWithin(1e-3f).of(86.80f) + assertThat(HumeDara2Handler.weightKgFromFrame(lockedFrame3)).isWithin(1e-3f).of(86.85f) + assertThat(HumeDara2Handler.weightKgFromFrame(lockedFrame4)).isWithin(1e-3f).of(86.95f) + assertThat(HumeDara2Handler.weightKgFromFrame(lockedFrame5)).isWithin(1e-3f).of(86.90f) + } + + @Test + fun `impedance decodes to plausible ohms and stays stable within a session`() { + // First-ever capture: bad/rushed foot contact, implausibly low — this is exactly what + // the plausible-range guard in HumeDara2Handler.publishFrame exists to reject. + assertThat(HumeDara2Handler.impedanceOhmsFromFrame(lockedFrame1)).isWithin(0.01).of(25.80) + + // Three consecutive readings in one session: same foot contact, same impedance. + assertThat(HumeDara2Handler.impedanceOhmsFromFrame(lockedFrame2)).isWithin(0.01).of(501.95) + assertThat(HumeDara2Handler.impedanceOhmsFromFrame(lockedFrame3)).isWithin(0.01).of(501.95) + assertThat(HumeDara2Handler.impedanceOhmsFromFrame(lockedFrame4)).isWithin(0.01).of(501.95) + + // A fresh BLE session later the same day: different (still plausible) contact. + assertThat(HumeDara2Handler.impedanceOhmsFromFrame(lockedFrame5)).isWithin(0.01).of(450.75) + } + + @Test + fun `checksum matches every real capture`() { + for (frame in listOf(lockedFrame1, lockedFrame2, lockedFrame3, lockedFrame4, lockedFrame5)) { + assertThat(HumeDara2Handler.checksum(frame)).isEqualTo(frame[10].toInt() and 0xFF) + } + } + + @Test + fun `a corrupted frame fails its checksum`() { + val corrupted = lockedFrame3.copyOf().also { it[3] = 0x00 } // tamper with a weight byte + assertThat(HumeDara2Handler.checksum(corrupted)).isNotEqualTo(corrupted[10].toInt() and 0xFF) + } + + @Test + fun `locked frames are distinguished from the live weight stream`() { + for (frame in listOf(lockedFrame1, lockedFrame2, lockedFrame3, lockedFrame4, lockedFrame5)) { + assertThat(HumeDara2Handler.isLockedFrame(frame)).isTrue() + } + assertThat(HumeDara2Handler.isLockedFrame(liveFrame)).isFalse() + } +} From 4e7013ee2d99e565fb08cc84e3fac550fc0112ee Mon Sep 17 00:00:00 2001 From: OliE Date: Sat, 29 Aug 2026 12:04:30 +0200 Subject: [PATCH 2/2] Update ScaleFactory.kt --- .../java/com/health/openscale/core/bluetooth/ScaleFactory.kt | 3 --- 1 file changed, 3 deletions(-) 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 aa246bdde..dacd6ccca 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 @@ -176,9 +176,6 @@ class ScaleFactory @Inject constructor( HesleyHandler(), ExingtechY1Handler(), EbelterBodyFatB2Handler(), - // Same LeFu/0xFFF0 chip family; kept as siblings, not merged, since neither name - // ("Dara 2.0" / "Electronic Scale") collides with the other — see HumeDara2Handler's - // class doc for why it isn't FitTrackDaraHandler's differently-branded "Dara" either. HumeDara2Handler(), ExcelvanCF36xHandler(), EtekcityESF551Handler(),