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()
+ }
+}