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")