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