Skip to content

S400 dual_frequency: diagnostic impedance sensors show swapped 50 kHz / 250 kHz labels (calculations unaffected) #427

Description

@bpobocha

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions