Skip to content

Fix Huawei AH100 / CH100 measurement parsing (closes #1206, #1280)#1374

Open
danieldorigatti wants to merge 2 commits into
oliexdev:masterfrom
danieldorigatti:fix/ch100-measurement-parsing
Open

Fix Huawei AH100 / CH100 measurement parsing (closes #1206, #1280)#1374
danieldorigatti wants to merge 2 commits into
oliexdev:masterfrom
danieldorigatti:fix/ch100-measurement-parsing

Conversation

@danieldorigatti
Copy link
Copy Markdown

Fix Huawei AH100 / CH100 measurement parsing

Closes #1206 and #1280. Supersedes #1276.

Summary

  • Restore the v2.5.4 measurement decode for the Huawei AH100 / CH100 scales (Chipsea CST34M97, BLE service 0xFAA0).
  • Split the handler into HuaweiAH100Handler (advert AH100) and HuaweiCH100Handler (advert CH100). Until now HuaweiAH100Handler matched CH100 only, so real AH100 hardware was never picked up.
  • Move all byte-level primitives into a pure-Kotlin object so they can be unit-tested on the JVM.

The bug

HuaweiAH100Handler in 3.x stopped AES-CTR-decrypting the encrypted measurement frame, used invented field offsets, and computed weight as (1457 - byte) / 10. Reports from #1206 and #1280:

scale display app shows
95.0 kg 138.6 kg
28 % fat 180 % fat
today year 3084

First fixture from the new HuaweiAhCh100ProtocolTest (matches the capture in #1280):

Notification on 0xFAA2:
  bc100edef702c9bbf2176c755b6eccdd5b92b0

3.x master parser:
  weight   = (1457 - 0xF7) / 10        = 122.6 kg     (wrong)
  fat      = byte[20] (out of bounds)  = 0 / crash    (wrong)
  datetime = "now", no parse                          (lost)

This patch (same numbers v2.5.4 produced):
  aesCtr(deobfuscate(payload), magicKey, INITIAL_IV)
    = 01 ca 03 18 01 ea 07 05 13 08 0c 21 02 1c 02
  weight    = u16le[1..2] / 10 = 0x03ca / 10 = 97.0 kg
  fat       = u16le[3..4] / 10 = 0x0118 / 10 = 28.0 %
  datetime  = 2026-05-19 08:12:33 (Tuesday)
  user      = 1
  impedance = 540 ohm

Reconstructed from the v2.5.4 source and verified against captures from #1280.

  • Service 0xFAA0 with TX 0xFAA1 (write) and RX 0xFAA2 (notify).
  • Frames are MAC-XOR obfuscated; encrypted frames additionally use AES-128-CTR with magicKey derived from the auth token.
  • The measurement is split across two notifications (op=0x0E then op=0x8E). AES-CTR-decrypt the deobfuscated payload of the first half only; the second half carries derived BIA fields that v2.5.4 did not decode.
  • Decrypted measurement layout: [user, weightLE, fatLE, yearLE, month, day, hour, min, sec, dow, impedanceLE].
  • Plain 0xDB header: lengthByte = payload.size + 1. Encrypted 0xDC header: lengthByte = payload.size. The 3.x rewrite had the 0xDB length off by one.
  • USER_INFO payload (CMD 0x09) is the v2.5.4 14-byte layout: auth(7) || age | sexBit | height | 0x00 | weightLE | resistanceLE | 0x1C 0xE2.

Changes

  • New core/bluetooth/libs/HuaweiAhCh100Protocol.kt - pure-Kotlin object holding obfuscate, aesCtr, xorChecksum, buildAuthToken, deriveMagicKey, buildPlainCommand, buildEncryptedCommand, and parseMeasurement. INITIAL_KEY / INITIAL_IV are exposed via getters returning defensive copies.
  • New abstract HuaweiAhCh100ScaleHandler with the BLE state machine.
  • HuaweiAH100Handler rewritten as a thin subclass matching advert AH100.
  • New HuaweiCH100Handler matching advert CH100. Both products use the same Chipsea CST34M97 hardware (confirmed in Fixed Huawei AH100/CH100 measurement parsing #1276); splitting just lets AH100 hardware be detected and leaves room for firmware-specific divergence later.
  • Name matching strips trailing NULs, whitespace, and HUAWEI / HONOR prefixes so OEM rebrands still resolve to the right handler.

Notes

If the first half already contains at least 15 plaintext bytes (i.e. the scale shipped the full record in one notification under the negotiated 185-byte MTU), the handler decodes immediately and ignores any later 0x8E half. This means the default Balanced tuning works as well as the legacy 23-byte ATT MTU; no tuning profile needs to be pinned.

…liexdev#1280)

The 3.x Kotlin rewrite of the Huawei AH100/CH100 handler regressed the
measurement decoder in a few breaking ways. As reported in oliexdev#1206 and
oliexdev#1280, real-world readings come back as 138 kg / 180 % / year 3084 for
what the scale displays as 95.0 kg / 28.0 %. Root causes:

  * The two-half encrypted measurement frame is no longer AES-CTR
    decrypted; the handler reads XOR-deobfuscated bytes directly.
  * The byte layout was replaced with invented offsets and a fanciful
    `(1457 - byte) / 10` weight formula.
  * The plain-frame length byte was changed from v2.5.4's
    `payload.size + 1` to `payload.size`, which appears to break AUTH
    on at least some firmware revisions.
  * The handler matched advertised name `"CH100"` while declaring
    itself "Huawei AH100" — so a real AH100 scale (which advertises
    `"AH100"`) was not recognised at all.

This commit restores the v2.5.4 wire protocol exactly, splits AH100
and CH100 into siblings sharing a common base, and locks the protocol
behind unit tests so the regression cannot recur silently.

Code changes
------------

  * `core/bluetooth/libs/HuaweiAhCh100Protocol.kt` — new pure-Kotlin
    object holding all wire-level primitives: `obfuscate`, `aesCtr`,
    `xorChecksum`, `buildAuthToken`, `deriveMagicKey`, the plain /
    encrypted frame writers, and `parseMeasurement`. JVM-testable, no
    Android dependencies. `INITIAL_KEY` / `INITIAL_IV` are exposed as
    defensive copies so a stray mutation can't corrupt subsequent
    sessions.
  * `core/bluetooth/scales/HuaweiAhCh100ScaleHandler.kt` — new abstract
    base class containing the shared BLE state machine. Concrete
    subclasses only override the advertised name. `HISTORY_READ` is
    declared as a *capability* but not yet *implemented* — the legacy
    history-pull path is wired but not invoked from the lifecycle.
  * `core/bluetooth/scales/HuaweiAH100Handler.kt` rewritten as a thin
    subclass that matches advert `"AH100"`; new `HuaweiCH100Handler`
    matches `"CH100"`. The previous handler matched only `"CH100"`
    despite calling itself "Huawei AH100", so AH100 hardware was
    silently unsupported.
  * `core/bluetooth/ScaleFactory.kt` — register the new CH100 handler.

Wire-protocol corrections
-------------------------

  * Plain `0xDB` writer restored to `lengthByte = payload.size + 1`.
  * Encrypted `0xDC` writer kept at `lengthByte = payload.size`.
  * Measurement decode: AES-CTR-decrypt the *first half's*
    deobfuscated tail (matching v2.5.4) and parse the documented
    layout `[user, weightLE, fatLE, dateTime, impedanceLE]`.
  * MTU resilience: if the first half already contains ≥ 15 plaintext
    bytes the handler publishes immediately without waiting for the
    `0x8E` second half, so the default Balanced tuning profile (which
    bumps MTU to 185) works as well as the legacy 23-byte MTU. We do
    not pin a tuning profile.
  * `supportFor` matches advertised names tolerantly: trailing NULs /
    whitespace and `HUAWEI ` / `HONOR ` prefixes are stripped before
    comparison. Some real-world devices advertise `'CH100'` followed
    by 15 NUL bytes, which the previous strict equality rejected.

Verification
------------

  * 18 new JVM unit tests in `HuaweiAhCh100ProtocolTest` lock every
    primitive plus three real-shaped fixture frames (97.0 kg / 28.0 %,
    63.6 kg, 90.0 kg). Fixtures are generated by an independent Python
    re-implementation of the v2.5.4 protocol; the Kotlin tests are
    green only if both ports agree on the bytes.
  * `./gradlew :app:testDebugUnitTest` — 100/100 tests pass across all
    suites (no existing tests broken).
  * `./gradlew assembleDebug` — succeeds, same path the upstream
    `ci_master` workflow uses.
  * Tested end-to-end on physical Huawei CH100 hardware against the
    user that filed oliexdev#1280; reading is now correct.

Note for users migrating from openScale 2.5.x or Huawei Health: you may
need to "Forget" the scale once in Android Bluetooth settings before
the first connection. The link-layer encryption keys from the previous
bond don't survive this protocol's re-pair flow and otherwise cause a
`CONNECTION_TERMINATED_MIC_FAILURE` after the first command.

Refs: oliexdev#1206 oliexdev#1246 oliexdev#1276 oliexdev#1280
private fun ts(d: Date) = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(d)
class HuaweiAH100Handler : HuaweiAhCh100ScaleHandler() {
override val supportedAdvertName: String = "AH100"
override val displayName: String = "Huawei AH100"
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for that class anymore should be handled in HuaweiAhCh100Handler

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HuaweiAH100Handler and HuaweiCH100Handler are removed; the single HuaweiAhCh100Handler now matches both the AH100 and CH100 advertisement names.

* previous bond don't survive this protocol's re-pair flow and otherwise
* cause `CONNECTION_TERMINATED_MIC_FAILURE` after the first command.
*/
abstract class HuaweiAhCh100ScaleHandler : ScaleDeviceHandler() {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rename the class name for Consistency to HuaweiAhCh100Handler

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HuaweiAhCh100ScaleHandler is renamed to HuaweiAhCh100Handler and is now a concrete class.

*
* @see HuaweiAhCh100ProtocolTest
*/
object HuaweiAhCh100Protocol {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this protocol class from the libs and integrate them into the HuaweiAhCh100Handler

Copy link
Copy Markdown
Author

@danieldorigatti danieldorigatti May 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HuaweiAhCh100Protocol is removed from libs/; its constants and wire-protocol functions are now an internal companion object inside HuaweiAhCh100Handler. It was in libs/ so it could be JVM-unit-tested without a real scale; HuaweiAhCh100ProtocolTest is retargeted as HuaweiAhCh100HandlerTest against the companion object, so the #1206/#1280 regression stays locked.

* `HuaweiAhCh100ProtocolTest` lock the protocol so the regression cannot
* recur silently.
*/
class HuaweiCH100Handler : HuaweiAhCh100ScaleHandler() {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be better handled in HuaweiAhCh100Handler

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HuaweiCH100Handler is removed; HuaweiAhCh100Handler handles the CH100 advertisement name.

Addresses review feedback on oliexdev#1374: merge HuaweiAH100Handler,
HuaweiCH100Handler, HuaweiAhCh100ScaleHandler and the libs
HuaweiAhCh100Protocol object into one HuaweiAhCh100Handler class.
The protocol primitives are now an internal companion object;
HuaweiAhCh100ProtocolTest is retargeted as HuaweiAhCh100HandlerTest.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Huawei CH100, New Framework

2 participants