Skip to content

feat: add Hume Body Pod 2 support - #1537

Merged
oliexdev merged 3 commits into
oliexdev:masterfrom
jorge-lavin:feat/body-pod-2-support
Oct 24, 2026
Merged

oliexdev merged 3 commits into
oliexdev:masterfrom
jorge-lavin:feat/body-pod-2-support

Conversation

@jorge-lavin

@jorge-lavin jorge-lavin commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • add BLE handler for Hume/Lefu Body Pod 2
  • configure FFF0/FFF1/FFF2/FFF4 GATT flow and user profile sync
  • parse stable weight and dual-frequency segment impedance packets
  • register device in ScaleFactory and ScaleCatalog

Supported features

  • live weight
  • body impedance
  • user profile sync

The handler publishes measured weight and whole-body impedance; OpenScale derives body-composition metrics centrally. Extended DF payloads remain intentionally unparsed.

Validation

  • JAVA_HOME=$HOME/.sdkman/candidates/java/current ./gradlew testDebugUnitTest
  • all debug unit tests pass

Integration tested with a debug apk using my own device

@jorge-lavin

Copy link
Copy Markdown
Contributor Author

@jorge-lavin

Copy link
Copy Markdown
Contributor Author

@oliexdev can you please take a look? Thanks!

@bascouat

Copy link
Copy Markdown

Independent verification of this handler against real hardware, from a Python re-implementation (offline — no app, account, cloud or location).

Hardware confirmation — the GATT shape matches exactly. Read from a real unit on Linux/BlueZ: advertised name Body Pod 2 (exact), manufacturer characteristic 0x2A29 = Lefu (the OEM this PR documents), service fff0, write fff1, notify fff2 + fff4 — exact match to BodyPod2Handler.kt. Scale MAC D4:E9:F4:42:17:6E.

Profile write confirmed byte-for-byte. FE 01 <sex> <activity> <height> <age> <unit> with XOR over bytes 1..6, exactly as the handler builds it. Sent: fe 01 01 00 b2 28 01 9b (XOR = 9b, verified).

Measurement — four independent barefoot captures, artifacts kept and hashed:

# weight kg whole-body ohm sha256 (prefix)
1 85.9 585.3 790ab616e436bd81
2 85.5 580.3 8b5835be5cd72765
3 85.5 582.85 ed441cd0035f0900
4 85.5 584.9 a4cf2a66ff433b10

The byte[2] & 0x80 lock bit transitioned 00 → 80 on every stand, after ~80 s of standing still. Four standalone stands land within 0.4 kg and 5 ohm (~0.9%) — a stable, repeatable decode, not a lucky frame.

Sample full decode (#1): weight 85.9 kg; low-frequency index 0 segments = 307.4, 311.3, 26.8, 242.1, 256.2 ohm; high-frequency index 1 = 267.6, 275.5, 24.5, 219.1, 229.5 ohm; segment order as your handler defines it (right-arm / left-arm / trunk / right-leg / left-leg); whole-body value (mean of the two segmental paths, inside the 100–1500 ohm gate) = 585.3 ohm.

Sanity signals the decode is not an artifact: low-frequency impedance exceeds high-frequency on every segment (the expected BIA direction), the trunk reads far lower than the limbs, and the lock bit stays 00 for a hand press and sets only on a genuine barefoot stand.

On the 0xDF extended packets. The PR leaves these intentionally unparsed. A real capture now lets me pin the layout rather than infer it: the four documented sub-packet index pairs (0,0) (1,0) (0,1) (1,1) arrive in exactly that order, and the frame shape is [df][0x02 marker][major][minor][12-byte payload][xor] — 17 bytes, decoding as six uint16 LE slots. Checksums over all-but-last pass and the pairs reproduce the documented set exactly. Caveat that keeps your choice the safe default: the six slots are reproducible but not yet semantically identified (their magnitudes don't match the 0xBE ohm scale), so "leave it unparsed" is still the right call — this is only groundwork if a decoder is ever wanted.

Two notes for the maintainer. (1) A BleakClient(address_string)-style address-only connect fails on Linux/BlueZ ("device not found") — pass the device object (Python-side caveat, no Kotlin change implied). (2) A dual-frequency body-impedance device needs bare skin on all four electrodes plus ~10–15 s standing still; a hand press yields weight frames but no 0xBE frames and never sets the lock bit — worth having as a fixture note.

Ref (corrected spelling): https://github.com/JozefJarosciak/Blue2Scale.com/blob/master/BLE_SCALE_PROTOCOL.md

@oliexdev

Copy link
Copy Markdown
Owner

Thanks for your PR.

BODY_COMPOSITION is listed as implemented, but the handler only publishes weight and impedance,
and nothing in openScale derives body composition from impedance centrally. Either compute it in
the handler with StandardImpedanceLib, as HumeDara2Handler does for the Dara 2.0 (same Lefu
family, with a plausibility range in front of it), or keep BODY_COMPOSITION in capabilities
only and drop it from implemented.

Minor:

  • The scale delivers five segments at two frequencies. If you want to keep them,
    KeepS3Handler shows how to store dual-frequency values as ble.* types, hidden by default.
  • validates CF and BE packet checksums builds the checksum with the same XOR it then checks.
    If you get the chance, a real 0xCF and 0xBE frame from your scale would make it meaningful.

jorge-lavin and others added 2 commits October 21, 2026 10:09
  Store the whole-body impedance of the high frequency in IMPEDANCE and of
  the low frequency in IMPEDANCE_LOW, as KeepS3Handler does, instead of ten
  per-segment types. Previously the low-frequency value ended up in
  IMPEDANCE ("Impedance (high)"). Drop the segment types, deviceOhm and
  their strings.

  Publish the weight when the impedance is implausible instead of dropping
  the whole weigh-in; only the out-of-range value is left out.

  Rebuild the test frames from the barefoot weigh-in posted in the PR so
  low and high frequency differ as on the real scale.
@oliexdev

Copy link
Copy Markdown
Owner

Thanks for the update, and sorry, my hint about the segments was misleading. KeepS3Handler
doesn't store per-segment values, it puts the whole-body impedance into the built-in IMPEDANCE
(high frequency) and IMPEDANCE_LOW. Those are internal types, hidden in the UI but kept in the
database so body composition can be derived from them later.

I pushed a commit to your branch that does the same here:

  • IMPEDANCE = whole-body value of the high-frequency segments, IMPEDANCE_LOW = the same for the
    low-frequency ones. Before, the low-frequency value ended up in IMPEDANCE, which the app labels
    "Impedance (high)".
  • Dropped the ten segment types, deviceOhm and the strings.
  • If the impedance is outside 100–1500 Ω, the weight is now published without it instead of the
    whole weigh-in being lost.
  • The test frames are rebuilt from @bascouat's barefoot weigh-in, so low and high differ as on the
    real scale.

If you get the chance, please check a weigh-in on your scale with this build. A raw 0xCF and both
0xBE frames from the same weigh-in would also let us replace the rebuilt test frames with real ones.

@oliexdev
oliexdev merged commit a212457 into oliexdev:master Oct 24, 2026
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.

3 participants