From 41efdd4b5c2c7977d4ca14dc3a0c32c6447e66ff Mon Sep 17 00:00:00 2001 From: Martin Riedel Date: Mon, 10 Aug 2026 10:53:44 +0200 Subject: [PATCH 1/5] Never send a zero weight in the Huawei user record A Huawei CH100 capture showed the scale answering our USER_INFO with USER_CHANGED 127 times in 33 seconds, re-requesting the record after every reply. The record we sent carried weight 0: sendUserInfo falls back to user.initialWeight when no measurement has been taken yet in the session, and that field is 0 for a freshly created profile. Add ScaleDeviceHandler.fallbackWeightKg(), which walks last stored measurement -> profile initial weight -> BMI-22 estimate from body height, and never returns 0. On the same hardware this drops the USER_CHANGED storm from 127 cycles to 5. HuaweiCH100SHandler had the identical line and is fixed with it. Also pin two things the previous fix got wrong on paper: the encrypted USER_INFO payload is 16 bytes, not 14, and the unit tests do not remove the need to verify against real hardware. Most fixtures in HuaweiAhCh100HandlerTest were generated by a second implementation of the same assumed layout, so they can only show that two ports of one guess agree. Add a section with frames captured off a real scale and checked against its display, including the 16th byte the documented layout does not mention. Not addressed: the body fat percentage. It arrives fully computed in the frame, and re-measuring with a correct user record moved it from 33.8% to 33.9% -- i.e. not at all. The scale's own algorithm produces it, and at the reported 313 ohm StandardImpedanceLib explicitly declines to do better. Co-Authored-By: Claude Opus 5 (1M context) --- .../bluetooth/scales/HuaweiAhCh100Handler.kt | 17 +++-- .../bluetooth/scales/HuaweiCH100SHandler.kt | 3 +- .../bluetooth/scales/ScaleDeviceHandler.kt | 26 ++++++++ .../scales/HuaweiAhCh100HandlerTest.kt | 55 ++++++++++++++++ .../ScaleDeviceHandlerWeightFallbackTest.kt | 66 +++++++++++++++++++ 5 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandlerWeightFallbackTest.kt 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..eb2fbb960 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. + // Total = 16 bytes (confirmed against a real CH100 capture). 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) + // Never 0: the scale rejects a weightless user record and re-requests it + // in a USER_CHANGED loop. See ScaleDeviceHandler.fallbackWeightKg. + val w = (weightTenthKg ?: (profileWeightKg(user) * 10f).toInt()).coerceAtLeast(1) val tail = ByteArrayOutputStream().apply { write(byteArrayOf(age.toByte(), height.toByte(), 0x00)) write(le16(w)) 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..8d9049fbe 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,8 @@ 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) + // Never 0: same USER_CHANGED loop as the AH100/CH100 sibling handler. + val w = (weightTenthKg ?: (profileWeightKg(user) * 10f).toInt()).coerceAtLeast(1) val payload = ByteArrayOutputStream().apply { write(authCode) write((age or sexBit) and 0xFF) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt index 7c8c755bf..92a088987 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt @@ -113,7 +113,33 @@ abstract class ScaleDeviceHandler { // Pseudo UUIDs for Classic/SPP val CLASSIC_DATA_UUID: UUID = UUID.fromString("00000000-0000-0000-0000-00000000C1A5") + + /** + * Pick the best known body weight in kg for a user record pushed to a scale. + * + * Never returns 0: a profile weight of zero is not "unknown" on the wire, it + * is a wrong value. The Huawei AH100 / CH100 rejects such a record and + * re-requests it in a tight USER_CHANGED loop (127 round trips observed in a + * single session), and any firmware that derives body composition from the + * pushed profile computes it against that garbage. + * + * Falls back to a BMI-22 estimate from body height, which is wrong but + * physiologically sane — and the first real measurement replaces it. + */ + fun fallbackWeightKg(lastKg: Float?, initialKg: Float, heightCm: Float): Float { + lastKg?.takeIf { it.isFinite() && it > 0f }?.let { return it } + initialKg.takeIf { it.isFinite() && it > 0f }?.let { return it } + val heightM = heightCm / 100f + return if (heightM.isFinite() && heightM > 0.5f) 22f * heightM * heightM else 70f + } } + + /** [fallbackWeightKg] for [user], using their last stored measurement. */ + protected fun profileWeightKg(user: ScaleUser): Float = fallbackWeightKg( + lastKg = lastMeasurementFor(user.id)?.weight, + initialKg = user.initialWeight, + heightCm = user.bodyHeight + ) /** * Identify whether this handler supports the given scanned device. * Return a [DeviceSupport] description if yes, or `null` if not. 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..dd1318282 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,56 @@ 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. The extra + // byte was 0x15 in both captures even though weight, fat and impedance + // all changed, so it is not a body-composition value. Pinned here so a + // future maintainer who works out what it means notices this test. + 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()) + } + } + // -- Helpers ------------------------------------------------------------- private fun assertFrameDecodes( diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandlerWeightFallbackTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandlerWeightFallbackTest.kt new file mode 100644 index 000000000..6d20c75a1 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandlerWeightFallbackTest.kt @@ -0,0 +1,66 @@ +/* + * 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 + +/** + * Tests for [ScaleDeviceHandler.fallbackWeightKg]. + * + * The property that matters is the last one: handlers push this value into a + * user record on the wire, and a real Huawei CH100 capture showed the scale + * answering a weight-0 record with 127 USER_CHANGED re-requests in 33 seconds. + */ +class ScaleDeviceHandlerWeightFallbackTest { + + @Test + fun `prefers the last measured weight`() { + assertThat(ScaleDeviceHandler.fallbackWeightKg(126.6f, 80f, 185f)).isEqualTo(126.6f) + } + + @Test + fun `falls back to the profile's initial weight`() { + assertThat(ScaleDeviceHandler.fallbackWeightKg(null, 80f, 185f)).isEqualTo(80f) + } + + @Test + fun `estimates from height when nothing is known`() { + // BMI 22 at 1.85 m + assertThat(ScaleDeviceHandler.fallbackWeightKg(null, 0f, 185f)).isWithin(0.1f).of(75.3f) + } + + @Test + fun `uses a last resort when height is missing too`() { + assertThat(ScaleDeviceHandler.fallbackWeightKg(null, 0f, -1f)).isEqualTo(70f) + } + + @Test + fun `never returns zero or a non-finite value`() { + val inputs = listOf(0f, -1f, Float.NaN, Float.POSITIVE_INFINITY) + for (last in inputs + null) { + for (initial in inputs) { + for (height in inputs) { + val w = ScaleDeviceHandler.fallbackWeightKg(last, initial, height) + assertThat(w).isGreaterThan(0f) + assertThat(w.isFinite()).isTrue() + } + } + } + } +} From 64641c9af30a409f27369da1a21e3a783d6f4626 Mon Sep 17 00:00:00 2001 From: Martin Riedel Date: Mon, 10 Aug 2026 11:10:26 +0200 Subject: [PATCH 2/5] Do not claim the undocumented 16th byte is meaningless The two captures behind that claim were 0.1 kg apart, so a slowly-varying value like visceral fat level would look constant either way. #547 reports visceral fat 11.5 at BMI 24.8 and 14 at BMI 27.5 from the vendor app; these captures are BMI 37 and the byte reads 21. Pin the value, do not explain it. Co-Authored-By: Claude Opus 5 (1M context) --- .../core/bluetooth/scales/HuaweiAhCh100HandlerTest.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 dd1318282..293c239c1 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 @@ -304,10 +304,12 @@ class HuaweiAhCh100HandlerTest { @Test fun `real capture - the frame carries one byte beyond the documented layout`() { - // parseMeasurement documents 15 bytes; the hardware sends 16. The extra - // byte was 0x15 in both captures even though weight, fat and impedance - // all changed, so it is not a body-composition value. Pinned here so a - // future maintainer who works out what it means notices this test. + // parseMeasurement documents 15 bytes; the hardware sends 16, and we + // drop the extra one. It was 0x15 = 21 in both captures — but those two + // measurements were 0.1 kg apart, so that says nothing about whether it + // varies. Candidate: visceral fat level. Issue #547 reports 11.5 at BMI + // 24.8 and 14 at BMI 27.5 from the vendor app, and these captures are + // BMI 37. Pinned so whoever decodes it has to come here. val mac = HuaweiAhCh100Handler.macStringToBytes(CAPTURE_MAC) val mk = HuaweiAhCh100Handler.deriveMagicKey( HuaweiAhCh100Handler.buildAuthToken(1), mac From 114551d635ef1f662f03a07ff5168f3fa2dd1c2d Mon Sep 17 00:00:00 2001 From: Martin Riedel Date: Mon, 10 Aug 2026 11:59:45 +0200 Subject: [PATCH 3/5] Declare 14, not 16, in the USER_INFO length byte The vendor app sends "DC 0E 09" followed by 16 encrypted bytes: the trailing 2-byte constant is transmitted but 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 moved the length byte to 16 with it. Measured on real hardware. Two idle sessions, connected, nobody standing on the scale, so the only variable is the length byte: 0x10 (16) 1026 USER_CHANGED polls over 274 s median 266 ms 0x0E (14) 533 USER_CHANGED polls over 532 s median 1001 ms The scale polls four times slower once the record is declared the way its own app declares it. Note what this does NOT show: the same comparison across the weight fix in the previous commit finds no difference (245 ms with weight 0 vs 266 ms with a correct weight, both at 0x10). That fix stands on sending correct data, not on this behaviour -- see the correction in the PR description. Co-Authored-By: Claude Opus 5 (1M context) --- .../bluetooth/scales/HuaweiAhCh100Handler.kt | 22 ++++++++++--- .../scales/HuaweiAhCh100HandlerTest.kt | 32 ++++++++++++++++--- 2 files changed, 44 insertions(+), 10 deletions(-) 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 eb2fbb960..5a12d9bcf 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 @@ -437,7 +437,10 @@ 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()) + // The 2-byte trailer is transmitted but not counted, as in the vendor app. + val frame = buildEncryptedCommand( + CMD_USER_INFO, payload, mk, macBytes(), explicitLen = payload.size - USER_INFO_TRAILER + ) logD("→ CMD* 0x%02X len=%d (encrypted)".format(CMD_USER_INFO.toInt() and 0xFF, payload.size)) writeTo(SERVICE, CHAR_TX, frame, withResponse = true) } @@ -601,6 +604,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 ----------------------------------------------- /** @@ -690,18 +696,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/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 293c239c1..01779b8f3 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 @@ -305,11 +305,11 @@ class HuaweiAhCh100HandlerTest { @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. It was 0x15 = 21 in both captures — but those two - // measurements were 0.1 kg apart, so that says nothing about whether it - // varies. Candidate: visceral fat level. Issue #547 reports 11.5 at BMI - // 24.8 and 14 at BMI 27.5 from the vendor app, and these captures are - // BMI 37. Pinned so whoever decodes it has to come here. + // 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 @@ -321,6 +321,28 @@ class HuaweiAhCh100HandlerTest { } } + @Test + fun `real capture - USER_INFO declares 14 while transmitting 16, as the vendor app does`() { + // Huawei's own app sends "DC 0E 09" followed by 16 encrypted bytes: the + // trailing 2-byte constant is outside the declared length. Decoded from + // the btsnoop captures attached to issue #547. + val mac = HuaweiAhCh100Handler.macStringToBytes(CAPTURE_MAC) + val mk = HuaweiAhCh100Handler.deriveMagicKey( + HuaweiAhCh100Handler.buildAuthToken(1), mac + ) + val payload = ByteArray(16) + + val frame = HuaweiAhCh100Handler.buildEncryptedCommand( + HuaweiAhCh100Handler.CMD_USER_INFO, payload, mk, mac, + explicitLen = payload.size - HuaweiAhCh100Handler.USER_INFO_TRAILER + ) + + assertThat(frame[0]).isEqualTo(HuaweiAhCh100Handler.FRAME_ENCRYPTED) + assertThat(frame[1]).isEqualTo(0x0E.toByte()) + assertThat(frame[2]).isEqualTo(HuaweiAhCh100Handler.CMD_USER_INFO) + assertThat(frame.size).isEqualTo(3 + 16) + } + // -- Helpers ------------------------------------------------------------- private fun assertFrameDecodes( From 8142b8ce82714770a0589a4272d2e2efd19f125d Mon Sep 17 00:00:00 2001 From: oliexdev Date: Tue, 11 Aug 2026 18:25:07 +0200 Subject: [PATCH 4/5] Inline the user-record weight fallback into the Huawei handlers --- .../bluetooth/scales/HuaweiAhCh100Handler.kt | 18 ++++- .../bluetooth/scales/HuaweiCH100SHandler.kt | 14 +++- .../bluetooth/scales/ScaleDeviceHandler.kt | 26 -------- .../ScaleDeviceHandlerWeightFallbackTest.kt | 66 ------------------- 4 files changed, 29 insertions(+), 95 deletions(-) delete mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandlerWeightFallbackTest.kt 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 5a12d9bcf..fa8d700b2 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 @@ -390,8 +390,6 @@ class HuaweiAhCh100Handler : ScaleDeviceHandler() { val sexBit = if (user.gender.isMale()) 0x00 else 0x80 val age = (user.age and 0xFF) or sexBit val height = user.bodyHeight.toInt() and 0xFF - // Never 0: the scale rejects a weightless user record and re-requests it - // in a USER_CHANGED loop. See ScaleDeviceHandler.fallbackWeightKg. val w = (weightTenthKg ?: (profileWeightKg(user) * 10f).toInt()).coerceAtLeast(1) val tail = ByteArrayOutputStream().apply { write(byteArrayOf(age.toByte(), height.toByte(), 0x00)) @@ -406,6 +404,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 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 8d9049fbe..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,6 @@ 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 - // Never 0: same USER_CHANGED loop as the AH100/CH100 sibling handler. val w = (weightTenthKg ?: (profileWeightKg(user) * 10f).toInt()).coerceAtLeast(1) val payload = ByteArrayOutputStream().apply { write(authCode) @@ -322,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/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt index 92a088987..7c8c755bf 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt @@ -113,33 +113,7 @@ abstract class ScaleDeviceHandler { // Pseudo UUIDs for Classic/SPP val CLASSIC_DATA_UUID: UUID = UUID.fromString("00000000-0000-0000-0000-00000000C1A5") - - /** - * Pick the best known body weight in kg for a user record pushed to a scale. - * - * Never returns 0: a profile weight of zero is not "unknown" on the wire, it - * is a wrong value. The Huawei AH100 / CH100 rejects such a record and - * re-requests it in a tight USER_CHANGED loop (127 round trips observed in a - * single session), and any firmware that derives body composition from the - * pushed profile computes it against that garbage. - * - * Falls back to a BMI-22 estimate from body height, which is wrong but - * physiologically sane — and the first real measurement replaces it. - */ - fun fallbackWeightKg(lastKg: Float?, initialKg: Float, heightCm: Float): Float { - lastKg?.takeIf { it.isFinite() && it > 0f }?.let { return it } - initialKg.takeIf { it.isFinite() && it > 0f }?.let { return it } - val heightM = heightCm / 100f - return if (heightM.isFinite() && heightM > 0.5f) 22f * heightM * heightM else 70f - } } - - /** [fallbackWeightKg] for [user], using their last stored measurement. */ - protected fun profileWeightKg(user: ScaleUser): Float = fallbackWeightKg( - lastKg = lastMeasurementFor(user.id)?.weight, - initialKg = user.initialWeight, - heightCm = user.bodyHeight - ) /** * Identify whether this handler supports the given scanned device. * Return a [DeviceSupport] description if yes, or `null` if not. diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandlerWeightFallbackTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandlerWeightFallbackTest.kt deleted file mode 100644 index 6d20c75a1..000000000 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandlerWeightFallbackTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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 - -/** - * Tests for [ScaleDeviceHandler.fallbackWeightKg]. - * - * The property that matters is the last one: handlers push this value into a - * user record on the wire, and a real Huawei CH100 capture showed the scale - * answering a weight-0 record with 127 USER_CHANGED re-requests in 33 seconds. - */ -class ScaleDeviceHandlerWeightFallbackTest { - - @Test - fun `prefers the last measured weight`() { - assertThat(ScaleDeviceHandler.fallbackWeightKg(126.6f, 80f, 185f)).isEqualTo(126.6f) - } - - @Test - fun `falls back to the profile's initial weight`() { - assertThat(ScaleDeviceHandler.fallbackWeightKg(null, 80f, 185f)).isEqualTo(80f) - } - - @Test - fun `estimates from height when nothing is known`() { - // BMI 22 at 1.85 m - assertThat(ScaleDeviceHandler.fallbackWeightKg(null, 0f, 185f)).isWithin(0.1f).of(75.3f) - } - - @Test - fun `uses a last resort when height is missing too`() { - assertThat(ScaleDeviceHandler.fallbackWeightKg(null, 0f, -1f)).isEqualTo(70f) - } - - @Test - fun `never returns zero or a non-finite value`() { - val inputs = listOf(0f, -1f, Float.NaN, Float.POSITIVE_INFINITY) - for (last in inputs + null) { - for (initial in inputs) { - for (height in inputs) { - val w = ScaleDeviceHandler.fallbackWeightKg(last, initial, height) - assertThat(w).isGreaterThan(0f) - assertThat(w.isFinite()).isTrue() - } - } - } - } -} From a9a366cd48fa9c392135e9b7bb36d144e9fcf6c6 Mon Sep 17 00:00:00 2001 From: Martin Riedel Date: Sat, 29 Aug 2026 15:19:58 +0200 Subject: [PATCH 5/5] Pin the user record with tests that can actually fail The USER_INFO test asserted a length byte it passed in itself: it built a frame with explicitLen = 14 and then checked the frame said 14. It would have stayed green if sendUserInfo went back to declaring 16 -- the same defect this PR is about, in the test that was meant to catch it. Replace it with HuaweiUserRecordWireTest, which attaches both handlers to a capturing transport, drives connect/wake/auth, and decrypts the record off the wire. Verified the tests can fail: reverting the weight fallback and the length byte turns 5 of the 7 red, and the 2 that stay green are the ones that do not depend on those lines. Fixtures are synthetic -- the tests derive every expected value from the protocol, so there is no reason to put a real device address or real body measurements in the repository. Also log both lengths for USER_INFO. A log line reading len=16 next to a frame reading 0x0E is how the length byte went unnoticed in the first place, and the payload comment claiming "Total = 16 bytes" over a frame that declares 14 was the same trap for the next reader. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FofPiyxen5QkNMQqk4gzA4 --- .../bluetooth/scales/HuaweiAhCh100Handler.kt | 15 +- .../scales/HuaweiAhCh100HandlerTest.kt | 26 +- .../scales/HuaweiUserRecordWireTest.kt | 351 ++++++++++++++++++ 3 files changed, 368 insertions(+), 24 deletions(-) create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiUserRecordWireTest.kt 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 fa8d700b2..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 @@ -386,7 +386,9 @@ 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 = 16 bytes (confirmed against a real CH100 capture). + // 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 @@ -452,10 +454,17 @@ class HuaweiAhCh100Handler : ScaleDeviceHandler() { return } // 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 = payload.size - USER_INFO_TRAILER + 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 + ) ) - logD("→ CMD* 0x%02X len=%d (encrypted)".format(CMD_USER_INFO.toInt() and 0xFF, payload.size)) writeTo(SERVICE, CHAR_TX, frame, withResponse = true) } 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 01779b8f3..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 @@ -321,27 +321,11 @@ class HuaweiAhCh100HandlerTest { } } - @Test - fun `real capture - USER_INFO declares 14 while transmitting 16, as the vendor app does`() { - // Huawei's own app sends "DC 0E 09" followed by 16 encrypted bytes: the - // trailing 2-byte constant is outside the declared length. Decoded from - // the btsnoop captures attached to issue #547. - val mac = HuaweiAhCh100Handler.macStringToBytes(CAPTURE_MAC) - val mk = HuaweiAhCh100Handler.deriveMagicKey( - HuaweiAhCh100Handler.buildAuthToken(1), mac - ) - val payload = ByteArray(16) - - val frame = HuaweiAhCh100Handler.buildEncryptedCommand( - HuaweiAhCh100Handler.CMD_USER_INFO, payload, mk, mac, - explicitLen = payload.size - HuaweiAhCh100Handler.USER_INFO_TRAILER - ) - - assertThat(frame[0]).isEqualTo(HuaweiAhCh100Handler.FRAME_ENCRYPTED) - assertThat(frame[1]).isEqualTo(0x0E.toByte()) - assertThat(frame[2]).isEqualTo(HuaweiAhCh100Handler.CMD_USER_INFO) - assertThat(frame.size).isEqualTo(3 + 16) - } + // 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 ------------------------------------------------------------- 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")