diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100Handler.kt index 127d129bc..ac472bccc 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100Handler.kt @@ -43,8 +43,15 @@ import kotlin.math.min * The wire-protocol primitives — XOR obfuscation, AES-CTR, frame builders * and the measurement parser — are the [Companion] object at the bottom of * this file. They are pure Kotlin (no Android dependencies), so the parsing - * logic is locked by the JVM unit tests in `HuaweiAhCh100HandlerTest` and we - * don't have to re-prove it on a real scale. + * logic is exercised by the JVM unit tests in `HuaweiAhCh100HandlerTest`. + * + * Note what those tests can and cannot show. Most of their fixtures were + * generated by a second implementation of the *same assumed* layout, so they + * prove the two ports agree — not that either matches the hardware. Only the + * "Real hardware capture" section holds bytes that came off a scale and were + * checked against its display. Add real captures there when you touch this + * protocol; a green suite built from our own assumptions is what let the 3.x + * regression below ship in the first place. * * History (so future maintainers don't repeat past mistakes): * @@ -379,11 +386,13 @@ class HuaweiAhCh100Handler : ScaleDeviceHandler() { // Encrypted USER_INFO payload format (matches v2.5.4 exactly): // auth(7) || age|sexBit(1) || height(1) || 0x00(1) || weightLE(2) // || resistanceLE(2) || 0x1C 0xE2 (2 constant) - // Total = 14 bytes. + // 16 bytes transmitted, of which the length byte declares 14 — the + // trailer is not counted. Do not reconcile those two numbers; they + // disagree on the wire too. See sendCmdEncrypted. val sexBit = if (user.gender.isMale()) 0x00 else 0x80 val age = (user.age and 0xFF) or sexBit val height = user.bodyHeight.toInt() and 0xFF - val w = (weightTenthKg ?: (user.initialWeight * 10f).toInt()).coerceAtLeast(0) + val w = (weightTenthKg ?: (profileWeightKg(user) * 10f).toInt()).coerceAtLeast(1) val tail = ByteArrayOutputStream().apply { write(byteArrayOf(age.toByte(), height.toByte(), 0x00)) write(le16(w)) @@ -397,6 +406,22 @@ class HuaweiAhCh100Handler : ScaleDeviceHandler() { sendCmdEncrypted(full) } + /** + * Body weight in kg for the USER_INFO record, never 0. + * + * A zero weight is not "unknown" on the wire, it is a wrong value: the scale + * re-requests the record in a USER_CHANGED loop, and firmware that derives body + * composition from the pushed profile computes it against that. Prefers the last + * stored measurement, then the profile weight, then a BMI-22 estimate from body + * height — wrong but physiologically sane, and replaced by the first real reading. + */ + private fun profileWeightKg(user: ScaleUser): Float { + lastMeasurementFor(user.id)?.weight?.takeIf { it.isFinite() && it > 0f }?.let { return it } + user.initialWeight.takeIf { it.isFinite() && it > 0f }?.let { return it } + val heightM = user.bodyHeight / 100f + return if (heightM.isFinite() && heightM > 0.5f) 22f * heightM * heightM else 70f + } + private fun sendGetVersion() = sendCmd(CMD_GET_VERSION, byteArrayOf()) @Suppress("unused") // hooked up when we re-enable history pulls @@ -428,8 +453,18 @@ class HuaweiAhCh100Handler : ScaleDeviceHandler() { logW("magicKey missing; dropping encrypted cmd 0x%02X".format(CMD_USER_INFO.toInt() and 0xFF)) return } - val frame = buildEncryptedCommand(CMD_USER_INFO, payload, mk, macBytes()) - logD("→ CMD* 0x%02X len=%d (encrypted)".format(CMD_USER_INFO.toInt() and 0xFF, payload.size)) + // The 2-byte trailer is transmitted but not counted, as in the vendor app. + val declaredLen = payload.size - USER_INFO_TRAILER + val frame = buildEncryptedCommand( + CMD_USER_INFO, payload, mk, macBytes(), explicitLen = declaredLen + ) + // Log both numbers. A log line reading len=16 next to a frame reading + // 0x0E is how the length byte went unnoticed in the first place. + logD( + "→ CMD* 0x%02X len=%d declared=%d (encrypted)".format( + CMD_USER_INFO.toInt() and 0xFF, payload.size, declaredLen + ) + ) writeTo(SERVICE, CHAR_TX, frame, withResponse = true) } @@ -592,6 +627,9 @@ class HuaweiAhCh100Handler : ScaleDeviceHandler() { const val CMD_AUTH: Byte = 36 const val CMD_BIND_USER: Byte = 37 + /** Bytes of the USER_INFO payload that are sent but not counted in the length byte. */ + const val USER_INFO_TRAILER = 2 + // ---------- Primitives ----------------------------------------------- /** @@ -681,18 +719,24 @@ class HuaweiAhCh100Handler : ScaleDeviceHandler() { * Build a host->scale AES-encrypted command frame (start byte * [FRAME_ENCRYPTED]). * - * Length byte equals plaintext payload size (matches v2.5.4's - * `lengthByte = payload.size + 0`). + * Length byte defaults to the plaintext payload size. + * + * @param explicitLen overrides it. USER_INFO needs this: the vendor app + * declares 14 while transmitting 16, i.e. the trailing 2-byte constant + * sits *outside* the declared length. v2.5.4's "Total = 14 bytes" note + * describes the same thing — the 3.x port pulled the trailer into the + * payload and shifted the length byte to 16 with it. */ fun buildEncryptedCommand( cmd: Byte, payload: ByteArray, magicKey: ByteArray, mac: ByteArray, - iv: ByteArray = INITIAL_IV + iv: ByteArray = INITIAL_IV, + explicitLen: Int? = null ): ByteArray { val encrypted = aesCtr(payload, magicKey, iv) - val header = byteArrayOf(FRAME_ENCRYPTED, payload.size.toByte(), cmd) + val header = byteArrayOf(FRAME_ENCRYPTED, (explicitLen ?: payload.size).toByte(), cmd) return header + obfuscate(encrypted, mac) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt index b73d103a8..28771987f 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt @@ -309,7 +309,7 @@ class HuaweiCH100SHandler : ScaleDeviceHandler() { private fun sendUserInfo(user: ScaleUser, weightTenthKg: Int?) { val sexBit = if (user.gender.isMale()) 0x00 else 0x80 val age = user.age and 0x7F - val w = (weightTenthKg ?: (user.initialWeight * 10f).toInt()).coerceAtLeast(0) + val w = (weightTenthKg ?: (profileWeightKg(user) * 10f).toInt()).coerceAtLeast(1) val payload = ByteArrayOutputStream().apply { write(authCode) write((age or sexBit) and 0xFF) @@ -321,6 +321,19 @@ class HuaweiCH100SHandler : ScaleDeviceHandler() { sendEncrypted(CMD_USER_INFO, payload) } + /** + * Body weight in kg for the USER_INFO record, never 0 — same reasoning as the + * AH100/CH100 sibling handler: the scale re-requests a weightless record in a + * USER_CHANGED loop. Prefers the last stored measurement, then the profile + * weight, then a BMI-22 estimate from body height. + */ + private fun profileWeightKg(user: ScaleUser): Float { + lastMeasurementFor(user.id)?.weight?.takeIf { it.isFinite() && it > 0f }?.let { return it } + user.initialWeight.takeIf { it.isFinite() && it > 0f }?.let { return it } + val heightM = user.bodyHeight / 100f + return if (heightM.isFinite() && heightM > 0.5f) 22f * heightM * heightM else 70f + } + // --- Wire helpers --------------------------------------------------------- private fun sendPlain(cmd: Byte, payload: ByteArray) { diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100HandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100HandlerTest.kt index b5714959f..b2402414c 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100HandlerTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100HandlerTest.kt @@ -55,6 +55,11 @@ class HuaweiAhCh100HandlerTest { private const val FIXTURE_USER_FRAME = "bc100edef702c9bbf2176c755b6eccdd5b92b0" private const val FIXTURE_LIGHT_FRAME = "bc100ede15036dbaf2176869547ce7dbaf91b0" private const val FIXTURE_HEAVY_FRAME = "bc100ede3904b1bbf2176a67404fedd82392b0" + + // Captured from real hardware (see the "Real hardware capture" section). + private const val CAPTURE_MAC = "5C:CA:D3:14:2B:B3" + private const val CAPTURE_FRAME_1 = "bc110eabf78678303ba462fc93d1db4b348861" + private const val CAPTURE_FRAME_2 = "bc110eabf48679303ba462fc93dce84b3f8861" } // -- Primitives ---------------------------------------------------------- @@ -264,6 +269,64 @@ class HuaweiAhCh100HandlerTest { assertThat(brokenWeight).isNotWithin(1e-4f).of(97.0f) } + // -- Real hardware capture ----------------------------------------------- + // + // Everything above this line was generated by a Python re-implementation of + // the same assumed protocol, so it can only prove that two ports of one + // guess agree. The fixtures below came off an actual scale (advert name + // "CH100", Chipsea CST34M97) via logcat on 2026-08-10, decoded with the + // session MAC below, and were checked against what the scale's own display + // showed at that moment. They are the only bytes here that can fail when + // the assumption — not the implementation — is wrong. + + @Test + fun `real capture - CH100 hardware decodes to the value shown on its display`() { + val mac = HuaweiAhCh100Handler.macStringToBytes(CAPTURE_MAC) + val mk = HuaweiAhCh100Handler.deriveMagicKey( + HuaweiAhCh100Handler.buildAuthToken(1), mac + ) + + // Display read 126.6 kg while this frame was on the wire. + val first = HuaweiAhCh100Handler.decodeFirstHalf(hex(CAPTURE_FRAME_1), mk, mac) + assertThat(first.weightKg).isWithin(1e-4f).of(126.6f) + assertThat(first.fatPct).isWithin(1e-4f).of(33.8f) + assertThat(first.impedanceOhm).isEqualTo(306) + assertThat(first.userId).isEqualTo(1) + assertCalendar(first, 2026, Calendar.AUGUST, 10, 10, 1, 38) + + // Second measurement ~11 minutes later, same person, same session. + val second = HuaweiAhCh100Handler.decodeFirstHalf(hex(CAPTURE_FRAME_2), mk, mac) + assertThat(second.weightKg).isWithin(1e-4f).of(126.5f) + assertThat(second.fatPct).isWithin(1e-4f).of(33.9f) + assertThat(second.impedanceOhm).isEqualTo(313) + assertCalendar(second, 2026, Calendar.AUGUST, 10, 10, 12, 21) + } + + @Test + fun `real capture - the frame carries one byte beyond the documented layout`() { + // parseMeasurement documents 15 bytes; the hardware sends 16, and we + // drop the extra one. Dropping it is correct: the vendor-app captures + // attached to #547 decode to 13 measurements of one AH100 over 11 days, + // and this byte is 0x4E on every one of them while weight (87.3-93.2 kg), + // body fat (22.9-25.3 %) and impedance (373-451 Ω) all move. It is a + // per-device constant, not a body value. + val mac = HuaweiAhCh100Handler.macStringToBytes(CAPTURE_MAC) + val mk = HuaweiAhCh100Handler.deriveMagicKey( + HuaweiAhCh100Handler.buildAuthToken(1), mac + ) + for (frameHex in listOf(CAPTURE_FRAME_1, CAPTURE_FRAME_2)) { + val raw = HuaweiAhCh100Handler.decodeFirstHalf(hex(frameHex), mk, mac).rawDecrypted + assertThat(raw).hasLength(16) + assertThat(raw[15]).isEqualTo(0x15.toByte()) + } + } + + // The USER_INFO record the handler sends is pinned in + // HuaweiUserRecordWireTest, which drives the state machine and reads the + // bytes off the transport. Asserting it here would mean building the frame + // with the same arguments the assertion checks — green whatever + // sendUserInfo does. + // -- Helpers ------------------------------------------------------------- private fun assertFrameDecodes( diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiUserRecordWireTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiUserRecordWireTest.kt new file mode 100644 index 000000000..81f30d883 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiUserRecordWireTest.kt @@ -0,0 +1,351 @@ +/* + * 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 com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.CoroutineScope +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Date +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.coroutines.EmptyCoroutineContext + +/** + * The USER_INFO record as it actually leaves the handler. + * + * [HuaweiAhCh100HandlerTest] calls the companion primitives directly. That + * cannot catch a handler which stops calling them correctly: a test that + * builds a frame with `explicitLen = 14` and then asserts the frame says 14 + * is green no matter what `sendUserInfo` does. These tests drive the real + * state machine instead — attach, connect, wake, auth — and decode the bytes + * the handler hands to the transport. + * + * Two properties are pinned here, both from issue #1449: + * + * 1. The record never carries weight 0. `initialWeight` is `0f` on a freshly + * created profile, and the in-session weight is unset until the first + * measurement, so the naive fallback sent a user who weighs nothing. + * 2. The length byte declares 14 while 16 bytes are transmitted. openScale + * 2.5.4 did this — `BluetoothHuaweiAH100.java:524` at tag `v2.5.4` passes + * the literal `14` for a payload it builds as 16 bytes, and + * `AHsendEncryptedCommand` writes that as `{0xDC, len + 0, cmd}`. The + * trailing `0x1C 0xE2` is transmitted but not counted. The 3.x port pulled + * the trailer into the payload and moved the length byte to 16 with it. + * + * Both handlers get the same treatment: the weight fallback is duplicated in + * [HuaweiAhCh100Handler] and [HuaweiCH100SHandler], and two copies drift. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class HuaweiUserRecordWireTest { + + companion object { + private const val TEST_MAC = "00:11:22:33:44:55" + private const val TEST_USER_ID = 1 + + /** + * Mirrors the crypto constants in both handlers. Duplicated on purpose: + * a test that imports the key from the code under test cannot notice + * the code changing it. + */ + private const val AES_KEY_HEX = "3DA2784AFB87B12A980FDE3456732156" + private const val AES_IV_HEX = "4EF764322FDA7632123DEB8790FEA219" + + private const val FRAME_ENCRYPTED = 0xDC.toByte() + private const val FRAME_NOTIFY_PLAIN = 0xBD.toByte() + private const val CMD_USER_INFO = 9.toByte() + private const val OP_WAKEUP = 0x00.toByte() + private const val OP_AUTH_RESULT = 0x26.toByte() + } + + // -- AH100 / CH100 ------------------------------------------------------- + + @Test + fun `AhCh100 - declares 14 while transmitting 16, as v2_5_4 and the vendor app do`() { + val frame = driveAhCh100ToUserInfo(attachedAhCh100()) + + assertThat(frame[0]).isEqualTo(FRAME_ENCRYPTED) + assertThat(frame[1]).isEqualTo(0x0E.toByte()) + assertThat(frame[2]).isEqualTo(CMD_USER_INFO) + assertThat(frame.size).isEqualTo(3 + 16) + } + + @Test + fun `AhCh100 - the two uncounted trailer bytes are still on the wire`() { + // The point of the length byte fix is that the trailer is *excluded + // from the count*, not dropped. Sending 14 bytes would be a different + // record than the vendor app sends. + val plain = decodeAhCh100Record(driveAhCh100ToUserInfo(attachedAhCh100())) + + assertThat(plain).hasLength(16) + assertThat(plain[14]).isEqualTo(0x1C.toByte()) + assertThat(plain[15]).isEqualTo(0xE2.toByte()) + } + + @Test + fun `AhCh100 - a fresh profile is never sent as weighing nothing`() { + // The defect reported in #1449, at the point where it goes on the wire. + val setup = attachedAhCh100( + user = profile(initialWeight = 0f, bodyHeight = 180f), + previous = null, + ) + + val weightTenthKg = weightFieldOf(decodeAhCh100Record(driveAhCh100ToUserInfo(setup))) + + assertThat(weightTenthKg).isNotEqualTo(0) + // BMI 22 at 1.80 m — wrong, but a body. Replaced by the first reading. + assertThat(weightTenthKg).isEqualTo(712) + } + + @Test + fun `AhCh100 - a stored measurement beats the BMI estimate`() { + val setup = attachedAhCh100( + user = profile(initialWeight = 0f, bodyHeight = 180f), + previous = ScaleMeasurement( + userId = TEST_USER_ID, + dateTime = Date(0L), + weight = 80.0f, + ), + ) + + val plain = decodeAhCh100Record(driveAhCh100ToUserInfo(setup)) + + assertThat(weightFieldOf(plain)).isEqualTo(800) + assertThat(plain[8].toInt() and 0xFF).isEqualTo(180) + } + + @Test + fun `AhCh100 - a profile without a height still gets a body`() { + // bodyHeight defaults to -1f, so the BMI estimate has nothing to work + // with. It still must not fall back to 0. + val setup = attachedAhCh100( + user = profile(initialWeight = 0f, bodyHeight = -1f), + previous = null, + ) + + assertThat(weightFieldOf(decodeAhCh100Record(driveAhCh100ToUserInfo(setup)))).isEqualTo(700) + } + + // -- CH100S -------------------------------------------------------------- + + @Test + fun `CH100S - a fresh profile is never sent as weighing nothing`() { + val setup = attachedCh100s( + user = profile(initialWeight = 0f, bodyHeight = 180f), + previous = null, + ) + + val weightTenthKg = weightFieldOf(decodeCh100sRecord(driveCh100sToUserInfo(setup))) + + assertThat(weightTenthKg).isNotEqualTo(0) + assertThat(weightTenthKg).isEqualTo(712) + } + + @Test + fun `CH100S - already declares its full payload, which carries no trailer`() { + // This handler builds a 14-byte record without the 0x1C 0xE2 trailer, + // so `payload.size` is already the number the vendor app declares. It + // needs no explicitLen — pinned so nobody "fixes" it to match the + // sibling handler and breaks it. + val frame = driveCh100sToUserInfo(attachedCh100s()) + + assertThat(frame[0]).isEqualTo(FRAME_ENCRYPTED) + assertThat(frame[1]).isEqualTo(0x0E.toByte()) + assertThat(frame[2]).isEqualTo(CMD_USER_INFO) + assertThat(frame.size).isEqualTo(3 + 14) + } + + // -- Driving the handlers ------------------------------------------------ + + private fun attachedAhCh100( + user: ScaleUser = profile(), + previous: ScaleMeasurement? = null, + ): Setup = attach(HuaweiAhCh100Handler(), "CH100", user, previous) + + private fun attachedCh100s( + user: ScaleUser = profile(), + previous: ScaleMeasurement? = null, + ): Setup = attach(HuaweiCH100SHandler(), "CH100S", user, previous) + + private fun attach( + handler: H, + advertName: String, + user: ScaleUser, + previous: ScaleMeasurement?, + ): Setup { + // supportFor() is where both handlers latch the scale MAC they need for + // the XOR obfuscation; skipping it makes every frame a no-op XOR. + val support = handler.supportFor( + ScannedDeviceInfo( + name = advertName, + address = TEST_MAC, + rssi = -50, + serviceUuids = emptyList(), + manufacturerData = null, + ) + ) + assertThat(support).isNotNull() + + val transport = CapturingTransport() + handler.attach( + transport = transport, + callbacks = SilentCallbacks(), + settings = InMemorySettings(), + data = FixedDataProvider(user, previous), + scope = CoroutineScope(EmptyCoroutineContext), + ) + return Setup(handler, transport, user) + } + + /** Run connect -> wake -> auth-ok and return the USER_INFO frame. */ + private fun driveAhCh100ToUserInfo(setup: Setup): ByteArray = + setup.drive() + + private fun driveCh100sToUserInfo(setup: Setup): ByteArray = + setup.drive() + + private fun Setup.drive(): ByteArray { + handler.handleConnected(user) + handler.handleNotification(NOTIFY_CHARACTERISTIC, notification(OP_WAKEUP)) + handler.handleNotification(NOTIFY_CHARACTERISTIC, notification(OP_AUTH_RESULT, 0x01)) + return transport.writes.last { it.isNotEmpty() && it[0] == FRAME_ENCRYPTED } + } + + /** Scale->host frame: `[0xBD, len, op, ...tail XOR'd with the MAC...]`. */ + private fun notification(op: Byte, vararg tail: Byte): ByteArray { + val body = if (tail.isEmpty()) byteArrayOf(0x01) else tail + return byteArrayOf(FRAME_NOTIFY_PLAIN, body.size.toByte(), op) + macXor(body) + } + + // -- Decoding what was sent ---------------------------------------------- + + /** `[0xDC, len, cmd] || macXor(AES(payload))` — the AH100/CH100 order. */ + private fun decodeAhCh100Record(frame: ByteArray): ByteArray { + val magicKey = HuaweiAhCh100Handler.deriveMagicKey( + HuaweiAhCh100Handler.buildAuthToken(TEST_USER_ID), + HuaweiAhCh100Handler.macStringToBytes(TEST_MAC), + ) + return aes(macXor(frame.copyOfRange(3, frame.size)), magicKey) + } + + /** `[0xDC, len, cmd] || AES(macXor(payload))` — the CH100S order. */ + private fun decodeCh100sRecord(frame: ByteArray): ByteArray = + macXor(aes(frame.copyOfRange(3, frame.size), hex(AES_KEY_HEX))) + + /** Weight is a little-endian tenth-kg field at offset 10 in both records. */ + private fun weightFieldOf(plain: ByteArray): Int = + (plain[10].toInt() and 0xFF) or ((plain[11].toInt() and 0xFF) shl 8) + + private fun macXor(data: ByteArray): ByteArray = + HuaweiAhCh100Handler.obfuscate(data, HuaweiAhCh100Handler.macStringToBytes(TEST_MAC)) + + private fun aes(data: ByteArray, key: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CTR/NoPadding") + cipher.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(key, "AES"), + IvParameterSpec(hex(AES_IV_HEX)), + ) + return cipher.doFinal(data) + } + + private fun hex(s: String): ByteArray = + ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() } + + // -- Fixtures and fakes -------------------------------------------------- + + private fun profile( + initialWeight: Float = 0f, + bodyHeight: Float = 180f, + ): ScaleUser = ScaleUser( + id = TEST_USER_ID, + birthday = Date(0L), + bodyHeight = bodyHeight, + initialWeight = initialWeight, + ) + + private data class Setup( + val handler: H, + val transport: CapturingTransport, + val user: ScaleUser, + ) + + private class CapturingTransport : ScaleDeviceHandler.Transport { + val writes = mutableListOf() + + override fun setNotifyOn(service: UUID, characteristic: UUID) = Unit + override fun write( + service: UUID, + characteristic: UUID, + payload: ByteArray, + withResponse: Boolean, + ) { + writes += payload.copyOf() + } + + override fun read(service: UUID, characteristic: UUID) = Unit + override fun disconnect() = Unit + override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean = true + } + + private class SilentCallbacks : ScaleDeviceHandler.Callbacks { + override fun onPublish(measurement: ScaleMeasurement) = Unit + override fun resolveString(resId: Int, vararg args: Any): String = "res:$resId" + } + + private class InMemorySettings : ScaleDeviceHandler.DriverSettings { + private val ints = mutableMapOf() + private val strings = mutableMapOf() + + override fun getInt(key: String, default: Int): Int = ints[key] ?: default + override fun putInt(key: String, value: Int) { + ints[key] = value + } + + override fun getString(key: String, default: String?): String? = strings[key] ?: default + override fun putString(key: String, value: String) { + strings[key] = value + } + + override fun remove(key: String) { + ints.remove(key) + strings.remove(key) + } + } + + private class FixedDataProvider( + private val user: ScaleUser, + private val previous: ScaleMeasurement?, + ) : ScaleDeviceHandler.DataProvider { + override fun currentUser(): ScaleUser = user + override fun usersForDevice(): List = listOf(user) + override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = + previous?.takeIf { userId == user.id } + } +} + +private val NOTIFY_CHARACTERISTIC: UUID = + UUID.fromString("0000faa2-0000-1000-8000-00805f9b34fb")