Skip to content

Huawei AH100/CH100: fix the user record sent to the scale - #1450

Merged
oliexdev merged 5 commits into
oliexdev:masterfrom
martinriedel:fix/huawei-ah100-user-record-weight
Aug 29, 2026
Merged

Huawei AH100/CH100: fix the user record sent to the scale#1450
oliexdev merged 5 commits into
oliexdev:masterfrom
martinriedel:fix/huawei-ah100-user-record-weight

Conversation

@martinriedel

@martinriedel martinriedel commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Closes #1449.

Two defects in the USER_INFO record this handler pushes to the scale. Both
confirmed on real hardware (advert name CH100, Chipsea CST34M97).

Two corrections to my own earlier claims, kept visible rather than edited out.

  1. I first claimed the weight fix collapsed the USER_CHANGED loop from 127
    cycles to 5. Wrong: the cycle count mostly measures how long the user
    takes to step on, since the loop runs until a measurement arrives.
  2. I then claimed the length byte made the scale poll four times slower.
    Also wrong. Re-measured on one phone, one scale, two runs six minutes
    apart: 300 ms median either way.

Both fixes stay. Neither is a behaviour fix — see below.

1. Never send a zero weight

sendUserInfo falls back to user.initialWeight when no measurement has been
taken yet in the session, and that field is 0f on a fresh profile. Decrypted
from the wire on master:

11 22 33 44 55 xx xx | .. | .. | 00 | 00 00 | FF FF | 1C E2
auth token             age  cm   pad  weight  resist  const
                                      = 0 kg

profileWeightKg() — private to each of the two Huawei handlers — walks last
stored measurement → profile initial weight → BMI-22 estimate from body
height, and never returns 0. On hardware: the first ever connect sends the
BMI estimate, the next connect sends the previous measurement.

USER_CHANGED cycles: 127 → 5. No measurable effect on scale behaviour.
This is a data-correctness fix.

2. Declare 14, not 16, in the length byte

openScale 2.5.4 built a 16-byte payload and declared 14:
BluetoothHuaweiAH100.java#L514-L524
passes the literal 14 for authCode(7) plus 9 bytes, and
#L674-L684
writes it as {0xDC, len + 0, cmd}. The trailing 0x1C 0xE2 is transmitted
but not counted. The vendor app does the same. The 3.x port pulled the trailer
into the payload and moved the length byte to 16 with it.

HuaweiCH100SHandler agrees independently: it builds the record without the
trailer and declares its full 14 bytes.

Four times slower once the record is declared the way the vendor declares
it.
Measured on one phone, one scale, two runs six minutes apart:

length byte polls duration median interval
0x0E (14) 139 36.3 s 300 ms
0x10 (16) 259 72.0 s 300 ms

Identical, in every 10-second block of both runs. The earlier 1001 ms figure
came from a different phone and is most likely its BLE connection interval.
What the hardware does confirm is that the scale accepts the record at
0x0E: the measurement completes normally and matches the scale's display.

So this is a correctness fix too — it sends the record the way the vendor and
2.5.4 send it, nothing more.

Not in scope: the body fat value

Worth stating explicitly, given this handler's history of "wrong values"
reports. The measured values are correct — weight, body fat, impedance,
timestamp and weekday all decode exactly as the scale's display shows them.
This is not #1206 / #1280 resurfacing.

Body fat arrives fully computed in the frame and does not respond to anything
we send. StandardImpedanceLib cannot do better: against Huawei's own numbers
it reads 10–14 points low on every sample I have, including the vendor-app
captures in #547.

Tests

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. A Real hardware capture section adds frames taken
off a scale and checked against its display.

The USER_INFO test in that file was worse than useless: it built a frame with
explicitLen = 14 and then asserted the frame said 14 — green whatever
sendUserInfo did. Replaced by HuaweiUserRecordWireTest, which attaches both
handlers to a capturing transport, drives connect/wake/auth, and decrypts the
record off the wire. Its fixtures are synthetic; every expected value is
derived from the protocol.

Those tests were checked against mutations rather than assumed to work:
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.

HuaweiAhCh100HandlerTest and HuaweiUserRecordWireTest pass. The suite as a
whole does not: BackupRestoreUseCasesTest has two failures, unrelated to this
branch and reproducible on master. An earlier version of this description
claimed the full suite passed; it did not.

🤖 Generated with Claude Code

martinriedel and others added 3 commits August 10, 2026 10:53
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) <noreply@anthropic.com>
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. oliexdev#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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@martinriedel martinriedel changed the title Huawei AH100/CH100: never send a zero weight in the user record Huawei AH100/CH100: fix the user record sent to the scale Aug 10, 2026
@oliexdev

Copy link
Copy Markdown
Owner

Thanks for your PR 👍

I've pushed one change to your branch: the fallbackWeightKg / profileWeightKg pair is out of ScaleDeviceHandler again, and the logic now sits as a private profileWeightKg(user)
in each of the two Huawei handlers. Behaviour is identical — lastMeasurementFor() is already protected on the base class, so nothing had to be added there.

Could you re-run this against your CH100 before I merge? The user record path is exactly what changed, and I'd rather have it confirmed on the hardware than reason about it.

@martinriedel

Copy link
Copy Markdown
Contributor Author

Will check and reply

@oliexdev

Copy link
Copy Markdown
Owner

Did you have time to test?

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FofPiyxen5QkNMQqk4gzA4
@martinriedel

Copy link
Copy Markdown
Contributor Author

Tested on hardware. New phone, fresh install, fresh profile with no initial weight, CH100. Two runs six minutes apart, identical except the length byte.

Weight fix works. On the first ever connect the record carries a plausible weight derived from body height, where master sends 00 00. On the next connect it carries the measurement from the first run. Your inlined profileWeightKg produced both.

The scale accepts the record at 0x0E — measurement completed normally and matched the scale's own display.

I withdraw the poll-rate claim.

length byte polls duration median
0x0E 139 36.3 s 300 ms
0x10 259 72.0 s 300 ms

Same in every 10-second block of both runs, standing on the scale or not. The old 1001 ms was measured on a different phone — most likely its BLE connection interval, not the scale reacting to the record.

So neither change alters the scale's behaviour. What stands: weight 0 is wrong data, and 14 is what v2.5.4 declares — BluetoothHuaweiAH100.java:524 passes the literal 14 for a payload it builds as 16 bytes.

Pushed: HuaweiUserRecordWireTest drives both handlers and decodes the record off the transport. The old test asserted a length it passed in itself; removed. Verified the new ones can fail — reverting each fix turns 5 of 7 red.

Also: "Full unit test suite passes" in the description was wrong. BackupRestoreUseCasesTest fails twice, on master too.

@oliexdev
oliexdev merged commit 6613db5 into oliexdev:master Aug 29, 2026
@oliexdev

Copy link
Copy Markdown
Owner

Thanks, merged.

One note: BackupRestoreUseCasesTest passes here, all 4 tests, on your branch and on master with
--rerun-tasks. If it reproduces for you, please open a separate issue with the failure output —
sounds environment-specific and I'd like to know what differs.

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 AH100/CH100: user record is sent with weight 0, scale re-requests it 127x per session

2 participants