Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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):
*
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 -----------------------------------------------

/**
Expand Down Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------------------------------------------------------
Expand Down Expand Up @@ -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(
Expand Down
Loading