Summary
In dual_frequency mode with a Xiaomi S400, the two diagnostic impedance sensors display the frequency labels the wrong way round. The computed body-composition values are correct - this is a display/labelling bug only, but it makes the diagnostic sensors actively misleading and it contradicts the invariant stated in the integration's own code.
Environment
- bodymiscale from HACS,
main as of 05fedc7
- Home Assistant OS,
xiaomi_ble integration
- Scale: Xiaomi Body Composition Scale S400 (
MJTZC01YM, cloud model yunmai.scales.ms104, EU version)
impedance_mode: dual_frequency, calculation_mode: science
Source entities (from xiaomi_ble)
sensor.<scale>_impedance = 596.5 Ω
sensor.<scale>_impedance_low = 541.9 Ω
Wired through the options flow exactly as the config-flow labels instruct:
| Config field |
Config-flow label (en.json) |
Entity assigned |
Value |
impedance_low |
"Low-frequency impedance sensor (50 kHz)" |
..._impedance |
596.5 |
impedance_high |
"High-frequency impedance sensor (250 kHz)" |
..._impedance_low |
541.9 |
That mapping is deliberate and follows the naming inversion documented in README_S400_UPGRADE.md and in the docstring of _get_z_lf.
Observed
sensor.<name>_impedance_50_khz = 542 Ω
sensor.<name>_impedance_250_khz = 596 Ω
Expected
sensor.<name>_impedance_50_khz = 596 Ω (Z_lf, the larger value)
sensor.<name>_impedance_250_khz = 542 Ω (Z_hf, the smaller value)
This is exactly the invariant the integration itself asserts, in impedance.py#L74-L78:
Therefore Z_lf (50 kHz) is ALWAYS numerically greater than Z_hf (250 kHz).
The two sensors as displayed violate it.
The maths is fine - only the labels are wrong
Worth stating explicitly so this is not mistaken for a calculation bug. Hand-checking get_extracellular_water against the displayed ECW/TBW of 39.3%:
Z_ratio = Z_hf / Z_lf = 541.9 / 596.5 = 0.9085
ECW/TBW = 0.32 + 0.08 × 0.9085 = 0.3927 → 39.3% ✅ matches
With the ratio inverted it would be 0.32 + 0.08 × 1.1008 = 0.4081 → 40.8%, which is not what is displayed. So _get_z_lf / _get_z_hf are doing their job: the max() / min() guard resolves the naming inversion correctly for every derived metric.
Root cause
The max() / min() guard is applied inside _get_z_lf / _get_z_hf, but the two diagnostic sensors bypass it. In metrics/__init__.py both are pure pass-throughs:
Metric.IMPEDANCE_LOW: MetricInfo([], lambda c, s: None, 0),
Metric.IMPEDANCE_HIGH: MetricInfo([], lambda c, s: None, 0),
and in sensor.py#L307-L327 they are given fixed frequency translation keys:
key=CONF_SENSOR_IMPEDANCE_HIGH, translation_key="impedance_high", # "Impedance (250 kHz)"
key=CONF_SENSOR_IMPEDANCE_LOW, translation_key="impedance_low", # "Impedance (50 kHz)"
So the raw source value is shown under a hardcoded frequency name, while every consumer of that value goes through the guard that swaps it. The guard and the labels disagree by construction, and on an S400 they will always disagree.
Suggested fix
Apply the same guard to the display path, so the sensor named "(50 kHz)" is guaranteed to carry max(z1, z2) and "(250 kHz)" carries min(z1, z2):
Metric.IMPEDANCE_LOW: MetricInfo([], lambda c, s: _get_z_lf(s), 0), # 50 kHz → max
Metric.IMPEDANCE_HIGH: MetricInfo([], lambda c, s: _get_z_hf(s), 0), # 250 kHz → min
That makes the diagnostic sensors self-consistent with the calculations and with the documented physics, and it stays correct no matter which way round the user wires the two source entities - the same robustness argument already made for the calculation path.
An alternative, if the intent is that these sensors mirror the raw configured entities verbatim, would be to drop the frequency from the translation keys (e.g. "Impedance A" / "Impedance B"). But given dual_frequency mode is S400-specific and the inversion is universal on that hardware, applying the guard seems the more useful of the two.
Related
Happy to open a PR if the first option is the direction you would prefer.
Summary
In
dual_frequencymode with a Xiaomi S400, the two diagnostic impedance sensors display the frequency labels the wrong way round. The computed body-composition values are correct - this is a display/labelling bug only, but it makes the diagnostic sensors actively misleading and it contradicts the invariant stated in the integration's own code.Environment
mainas of05fedc7xiaomi_bleintegrationMJTZC01YM, cloud modelyunmai.scales.ms104, EU version)impedance_mode: dual_frequency,calculation_mode: scienceSource entities (from
xiaomi_ble)Wired through the options flow exactly as the config-flow labels instruct:
en.json)impedance_low..._impedanceimpedance_high..._impedance_lowThat mapping is deliberate and follows the naming inversion documented in
README_S400_UPGRADE.mdand in the docstring of_get_z_lf.Observed
Expected
This is exactly the invariant the integration itself asserts, in
impedance.py#L74-L78:The two sensors as displayed violate it.
The maths is fine - only the labels are wrong
Worth stating explicitly so this is not mistaken for a calculation bug. Hand-checking
get_extracellular_wateragainst the displayed ECW/TBW of 39.3%:With the ratio inverted it would be
0.32 + 0.08 × 1.1008 = 0.4081 → 40.8%, which is not what is displayed. So_get_z_lf/_get_z_hfare doing their job: themax()/min()guard resolves the naming inversion correctly for every derived metric.Root cause
The
max()/min()guard is applied inside_get_z_lf/_get_z_hf, but the two diagnostic sensors bypass it. Inmetrics/__init__.pyboth are pure pass-throughs:and in
sensor.py#L307-L327they are given fixed frequency translation keys:So the raw source value is shown under a hardcoded frequency name, while every consumer of that value goes through the guard that swaps it. The guard and the labels disagree by construction, and on an S400 they will always disagree.
Suggested fix
Apply the same guard to the display path, so the sensor named "(50 kHz)" is guaranteed to carry
max(z1, z2)and "(250 kHz)" carriesmin(z1, z2):That makes the diagnostic sensors self-consistent with the calculations and with the documented physics, and it stays correct no matter which way round the user wires the two source entities - the same robustness argument already made for the calculation path.
An alternative, if the intent is that these sensors mirror the raw configured entities verbatim, would be to drop the frequency from the translation keys (e.g. "Impedance A" / "Impedance B"). But given
dual_frequencymode is S400-specific and the inversion is universal on that hardware, applying the guard seems the more useful of the two.Related
max()/min()guard were discussed.Happy to open a PR if the first option is the direction you would prefer.