Skip to content

S400 : implement dual impedance measurements#1367

Open
DanyPM wants to merge 2 commits into
oliexdev:masterfrom
DanyPM:mi_scale_2_dual_impedance
Open

S400 : implement dual impedance measurements#1367
DanyPM wants to merge 2 commits into
oliexdev:masterfrom
DanyPM:mi_scale_2_dual_impedance

Conversation

@DanyPM
Copy link
Copy Markdown

@DanyPM DanyPM commented May 14, 2026

The Xiaomi Body Composition Scale S400 is a dual-frequency BIA device. It broadcasts two BLE advertisements per weighing: a high-frequency packet (weight + heart rate + high-band impedance) and a low-frequency packet (low-band impedance). The pre-existing handler consumed only the first packet to arrive and discarded the second, then fed whichever impedance value happened to be in that packet into MiScaleLib — the single-frequency formula port shared with the Mi Body Composition Scale 2 / Eufy C20.

This produced two real problems:

  1. Non-deterministic outputs. Packet arrival order is not guaranteed by the firmware. When the low-frequency packet arrived first, its impedance value (300–900 Ω band) was substituted into formulas tuned for the high-frequency band (300–800 Ω band). Fat / muscle / bone could differ by 10–20 % between consecutive weighings of the same subject.
  2. No way to compute compartment models. Hanai mixture theory needs the low-frequency reading to derive extracellular water. Without aggregation, ECW / ICW / BCM were simply not available.

What changed

Packet pipeline

  • S400Decryptor now disambiguates the impedance band per packet (weight != 0 ⇒ high-band, weight == 0 ⇒ low-band) and returns one of two complementary payloads.
  • S400Aggregator (new) buffers per-MAC sessions until both packets land, with a 10 s timeout fallback so older firmware that only emits the high-band packet still finalizes, and a short dedup window so the scale's end-of-weighing re-broadcasts don't double-publish.
  • MiScaleS400Handler no longer returns CONSUMED_STOP after the first packet; it returns CONSUMED_KEEP_SCANNING until the aggregator finalizes.

Body composition

  • S400BodyComposition (new) replaces MiScaleLib for the S400 path. It implements a literature-grounded pipeline: Sun 2003 (TBW), De Lorenzo 1997 / Matthie 2005 Hanai mixture (ECW), Pace & Rathbun 1945 (FFM hydration constant), Janssen 2000 (SMM), Cunningham 1991/1980 (BMR), Bracco 1996 (foot-to-foot correction). Full reference list lives in the KDoc at the top of the file.
  • Input validation, per-compartment suppression rules, and a fallback path (Deurenberg 1991 / Mifflin-St Jeor / Heymsfield) are all in the same file. The KDoc on S400BodyComposition is the spec — there is no external design document.
  • MiScaleLib is unchanged and still serves the Mi Scale 2 and Eufy C20 handlers.

Per-device settings

MiScaleS400Handler.DeviceConfigurationUi() now exposes two radio pickers below the existing bind-key field:

  • Bone formulaMI_LEGACY (impedance-based regression, matches the output range users see in the scale's companion app) vs HEYMSFIELD (anthropometric, clinically defensible, no impedance input).
  • BMR formulaCUNNINGHAM_1991 (literature default) vs CUNNINGHAM_1980 (reproduces the BMR range users see in the scale's companion app; runs ~5–8 % higher than 1991).

Defaults are MI_LEGACY + CUNNINGHAM_1991. Persistence rides the existing DriverSettings.putString channel — no SettingsFacade changes. Storage keys: s400_bone_formula, s400_bmr_formula. Enum names are preserved across this change so existing user preferences keep working.

Schema

  • New MeasurementTypeKey entries: IMPEDANCE, IMPEDANCE_LOW, ECW, ICW, PROTEIN, BCM. New UnitType.OHM.
  • ScaleMeasurement.impedanceLow field added; the existing impedance field is reused as the high-frequency band.
  • DB bumped from v14 to v15. MIGRATION_14_15 seeds the six new MeasurementType rows with isEnabled = false so existing users don't see new charts appear after the update — they can opt in from the measurement-type settings screen. Six new translatable strings added to values/strings.xml.

Tests

  • S400AggregatorTest covers: in-order packets, out-of-order packets, timeout finalization with low-band still missing, dedup of a re-broadcast finalization, multi-device interleaving.
  • S400BodyCompositionTest covers: three reference subjects, label-swap edge case, unreliable-contact edge case, both bone options × both BMR options, validation rejects, foot-to-foot directional check. 15 tests.
  • S400DecryptorTest extended with a high-band and a low-band reference payload to lock in the disambiguation rule.

Edit: got confused with my previous Mi Scale 2 in PR name and commit (hence branch name) :-/

@DanyPM DanyPM changed the title MI Scale 2: implement dual impedance measurements S400 : implement dual impedance measurements May 14, 2026
@DanyPM DanyPM force-pushed the mi_scale_2_dual_impedance branch from 96feaee to 70dc847 Compare May 14, 2026 20:30
@DanyPM DanyPM force-pushed the mi_scale_2_dual_impedance branch from 70dc847 to 4f4c9a6 Compare May 15, 2026 13:06
@DanyPM DanyPM marked this pull request as ready for review May 15, 2026 13:06
@DanyPM
Copy link
Copy Markdown
Author

DanyPM commented May 15, 2026

Tested and works with my S400 ! Would love a review :-)

@JPFrancoia
Copy link
Copy Markdown
Contributor

JPFrancoia commented May 17, 2026

Hey 👋

I tested this branch.

Screenshots from Xiami app:

Screenshot_20260517-172522_Xiaomi Home Screenshot_20260517-172551_Xiaomi Home

Screenshots from openscale:

Screenshot_20260517-172206_openScale Screenshot_20260517-172628_openScale

Both measurements taken today, one after the other. I wiped all data from openscale before doing the measurement.

Observations:

  • The weight is exactly the same in both apps
  • There are options for the bone and BMR formulas now, in the scale's settings
  • The body fat % values are very different: 23.9% in xiaomi, 18.6% in openscale
  • BMR values are 1679 kcal (xiaomi) and 1769 kcal (openscale)
  • Bone: 3.3kg (xiaomi) vs 3.03kg (openscale)
  • Visceral fat: 8 (xiaomi) vs 15.7 (openscale)
  • Muscle %: 72% (xiaomi) vs 43.8% (openscale)

Which of the metrics are supposed to be affected by this PR?

The % body fat seems closer to reality, but very different from the xiaomi app. The bone values are now quite close between the 2 apps.

@Mushoz
Copy link
Copy Markdown

Mushoz commented May 17, 2026

Can you try the 4 possible combinations for BMR x Bone formula? Curious to see how they stack up to the numbers in Xiaomi

@JPFrancoia
Copy link
Copy Markdown
Contributor

DATE TIME BCM BMI BMR BODY_FAT BONE ECW HEART_RATE ICW IMPEDANCE IMPEDANCE_LOW LBM MUSCLE PROTEIN TDEE VISCERAL_FAT WATER WEIGHT Bone formula BMR formula
2026-05-17 18:14:21.046 37.934757 26.59 1927.4703 18.486156 3.0304294 26.308424 63 33.35971 430.5 383 64.88502 43.880524 12.495698 2312.96 15.748796 59.668137 79.6 legacy 1980
2026-05-17 18:13:25.191 37.934757 26.59 1927.4703 18.486156 3.2636 26.308424 65 33.35971 430.5 383 64.88502 43.880524 12.202768 2312.96 15.748796 59.668137 79.6 anthropometric 1980
2026-05-17 18:09:23.886 37.934757 26.59 1771.5164 18.486156 3.2636 26.308424 59 33.35971 430.5 383 64.88502 43.880524 12.202768 2125.81 15.748796 59.668137 79.6 anthropometric 1991
2026-05-17 17:22:57.239 37.838978 26.59 1769.0599 18.629026 3.0300786 26.288069 60 33.275482 431 384 64.77129 43.787323 12.467564 2122.87 15.748796 59.563553 79.6 legacy 1991

This was kinda expected given the description of the PR:

Bone formula — MI_LEGACY (impedance-based regression, matches the output range users see in the scale's companion app) vs HEYMSFIELD (anthropometric, clinically defensible, no impedance input).
BMR formula — CUNNINGHAM_1991 (literature default) vs CUNNINGHAM_1980 (reproduces the BMR range users see in the scale's companion app; runs ~5–8 % higher than 1991).

CUNNINGHAM_1980 runs a bit higher than 1991 so the BMR measurements run with 1980 are a bit higher.
The bone measurements with the "anthropometric" (ni impedance used for this one according to the PR) algorithm are higher than the "legacy" ones (that use impedance).

So it looks like Xiaomi (based on the smallest delta in Openscale):

  • Uses something similar to CUNNINGHAM_1991 for BMR
  • Use something similar to anthropometric for the bone mass

@Mushoz
Copy link
Copy Markdown

Mushoz commented May 17, 2026

Curious to see the big differences in muscle %, fat % and visceral fat. For each of these variables, which number is closer to reality you reckon?

And do you have Openscale numbers without this PR for comparison?

@JPFrancoia
Copy link
Copy Markdown
Contributor

I can only answer for the % fat, the reality is around 15%. So yes this PR could be considered an improvement for the body fat %. For the rest: I don't know. I don't know if the Xiaomi app is right or wrong. I don't even know if the Xiaomi app uses the 2 impedance values. I'd assume yes but you never know.

As far as I can tell, only the % fat was meaningfully affected through this MR.

Hence my initial question: which of the metrics are supposed to be affected by this PR? I suppose just the body fat?

For the other metrics, there are differences between what Xiaomi and Openscale report. But I don't know which one is right, apart from the body fat %.

COMMENT(27, R.string.measurement_type_comment, listOf(UnitType.NONE), listOf(InputFieldType.TEXT)),
USER(28, R.string.measurement_type_user, listOf(UnitType.NONE), listOf(InputFieldType.USER)),
IMPEDANCE(29, R.string.measurement_type_impedance, listOf(UnitType.OHM), listOf(InputFieldType.FLOAT)),
IMPEDANCE_LOW(30, R.string.measurement_type_impedance_low, listOf(UnitType.OHM), listOf(InputFieldType.FLOAT)),
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.

Could you please remove IMPEDANCE and IMPEDANCE_LOW as a separate measurement, normal user doesn't need that.

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.

I kept the DB rows and raw sync writes because the two impedance bands — 50 kHz and 250 kHz — are the only directly measured body-comp inputs. Everything else, including TBW, ECW, ICW, FFM, BF, SMM, BCM, and BMR via FFM, is derived from those bands plus user anthropometry.

I updated the PR to mark them as "internal" and not show that to user. I think it's relevant to keep raw impedance values in case we need reprocessing as method to derive the metric improve later

Feel free to tell me if you just want me to drop them instead.

Comment thread android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt
@DanyPM
Copy link
Copy Markdown
Author

DanyPM commented May 18, 2026

Recomputed your values from impedance and analysed

Expected differences (vendor convention vs literature) :

  • Muscle 72% vs 43.8% : different definitions, not different formulas. Mi Home's "muscle" is LBM/FFM (everything that isn't fat: organs + bone + water + muscle). openScale's "muscle" is SMM (skeletal muscle mass only) per Janssen 2000, MRI-validated. SMM ≈ 40-50% of body weight is normal for an adult male; 72% LBM is also normal. Both right, different label. Compare apples to apples: openScale's LBM row (62.2 kg → 83%) is the one to put next to Mi's 72%.

  • Visceral fat 8 vs 15.7 : VFI from BIA without a waist measurement is a vendor convention, not a measurement. Every brand (Tanita, Omron, Withings, Mi) ships its own 1-30 scale with its own undocumented regression. openScale uses an empirical anthropometric formula (height/weight/age only, no impedance input). The KDoc on S400BodyComposition explicitly flags this. Numbers won't agree across brands by design and absence of convention.

  • Bone 3.3 vs 3.03 : close. openScale ships two options (MI_LEGACY empirical regression, HEYMSFIELD = 0.041·W male). 3.03 ≈ MI_LEGACY default. BIA can't actually measure bone (high resistivity → negligible signal), so all "bone" outputs are weight-driven regressions; nothing reads bone here.

  • BMR 1679 vs 1769 (Δ 90 kcal) : within normal between-formula variance. openScale defaults to Cunningham 1991 (370 + 21.6·FFM); Mi probably uses Mifflin-St Jeor or its own tuned formula. Both options are exposed in S400 settings. Switch to CUNNINGHAM_1980 to land higher (~1820), or fall back to anthropometric Mifflin (~1690) for lower. None of these are "wrong", just different sources.

More surprising

  • BF% Mi 23.9 vs openScale 18.6 : openScale derives BF from TBW via Pace & Rathbun: FFM = TBW / 0.732, BF = W - FFM. With dual-freq R 442/391 on a 27 y/o 172 cm male at 74.5 kg, literature TBW gives ~46 L, FFM ~62.7 kg, BF ~11.8 kg = ~16%.
    Mi's 23.9% is hard to reconcile with the impedance values and Mi Home likely uses a regression tuned to make values "feel right" to consumer expectations, which tend to flatter the user upward into "needs improvement" territory to drive engagement. I would say that Mi parity is not a goal.

As a reminder: metrics affected by this PR

New or recomputed, driven by added dual-freq low-band impedance + literature pipeline:

Metric Status Source
WEIGHT unchanged scale passthrough
BMI unchanged weight/height
IMPEDANCE (high) unchanged scale, already existed
IMPEDANCE_LOW NEW scale, new packet B
ECW NEW Hanai mixture, needs low-freq
ICW NEW TBW − ECW
WATER (TBW) recomputed Sun 2003 (was vendor-supplied or absent)
LBM (FFM) recomputed TBW / 0.732
BODY_FAT recomputed W − FFM
MUSCLE (SMM) recomputed Janssen 2000
BONE recomputed user-pickable: MI_LEGACY or HEYMSFIELD
VISCERAL_FAT recomputed empirical regression; also unrelated bugfix in prior commit
BMR recomputed Cunningham via FFM (was Mifflin-St Jeor)
TDEE recomputed derives from new BMR
PROTEIN NEW 0.20·FFM − bone
BCM NEW ICW / 0.70 (Kotler 1996)

@Mushoz
Copy link
Copy Markdown

Mushoz commented May 20, 2026

Why is visceral fat not using impedance? Using just weight, height and age will result in lean & muscular body builders having just as high visceral fat as a high body fat % person with identical height, weight and age.

I am currently still using the Mi body composition scale 2 with openscale, and AFAIK I have seen different visceral fat measurements at the same weight. And since I didn't change in height and age, it sounds like it is taking something else into account.

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.

4 participants