Skip to main content

Standalone Python client for the Renpho ES-CS20M BLE scale.

Project description

Renpho ES-CS20M BLE

PyPI Python versions CI License: MIT

This package provides a basic unofficial interface for interacting with Renpho's ES-CS20M scale using Bluetooth Low Energy (BLE).

Version Status

v0.1.2:

  • ✅ Initial release + metadata read support.
  • ✅ Three operating modes: weight-only, fixed-user (with body fat), and async user-detection.
  • ✅ Adds cached battery_level and firmware_revision metadata fields on scale instances.

Disclaimer: This is an unofficial, community-developed library. It is not affiliated with, officially maintained by, or in any way officially connected with Renpho, its parent companies, subsidiaries, or affiliates. The official Renpho website can be found at https://www.renpho.com. The names "Renpho" and "ES-CS20M", as well as related names, marks, emblems, and images, are registered trademarks of their respective owners.

Buy Me A Coffee

Features

  • Live weight and body fat readings from the scale's notification stream.
  • Guest-mode protocol — coexists safely with users registered by the official Renpho app on the same scale.
  • Three modes: fixed-user (with Profile), user-detection (with async resolver), and weight-only.
  • BodyMetrics derives 9 body-composition metrics from a stable reading: BMI, fat-free weight, body water %, skeletal muscle %, muscle mass, bone mass, protein %, BMR, and a body fat % passthrough.
  • Optional RX/TX payload logging via standard Python logging.

Installation

pip install renpho-escs20m

PyPI uses the hyphenated name renpho-escs20m; the import name uses underscores: import renpho_escs20m.

Quick Start

Weight only (no body fat)

import asyncio
from renpho_escs20m import RenphoESCS20MScale, ScaleData, WEIGHT_KEY, WeightUnit


def notification_callback(data: ScaleData):
    print(f"weight={data.measurements[WEIGHT_KEY]} kg")


async def main():
    scale = RenphoESCS20MScale(
        'XX:XX:XX:XX:XX:XX', notification_callback, WeightUnit.KG,
    )
    await scale.async_start()
    await asyncio.sleep(30)
    await scale.async_stop()


asyncio.run(main())

Fixed user + body metrics

import asyncio
from renpho_escs20m import (
    BODY_FAT_KEY, BodyMetrics, Profile, RenphoESCS20MScale,
    ScaleData, Sex, WEIGHT_KEY, WeightUnit,
)


PROFILE = Profile(
    sex=Sex.Male,
    age=43,
    height_m=1.70,
    athlete=False,
    algorithm=0x04,        # see "Body fat algorithm" below
)


def notification_callback(data: ScaleData):
    weight = data.measurements.get(WEIGHT_KEY)
    body_fat = data.measurements.get(BODY_FAT_KEY)
    if weight is not None and body_fat is not None:
        m = BodyMetrics(
            weight_kg=weight,
            height_m=PROFILE.height_m,
            age=PROFILE.age,
            sex=PROFILE.sex,
            body_fat_percentage=body_fat,
        )
        print(
            f"weight={weight} kg  bmi={m.body_mass_index}  "
            f"bf%={m.body_fat_percentage}  bmr={m.basal_metabolic_rate}"
        )
    elif weight is not None:
        print(f"weight={weight} kg  bmi={round(weight / PROFILE.height_m**2, 1)}")


async def main():
    scale = RenphoESCS20MScale(
        'XX:XX:XX:XX:XX:XX',
        notification_callback,
        WeightUnit.KG,
        profile=PROFILE,
    )
    await scale.async_start()
    await asyncio.sleep(30)
    await scale.async_stop()


asyncio.run(main())

User detection from weight

import asyncio
from renpho_escs20m import (
    Profile, RenphoESCS20MScale, ScaleData, Sex, WEIGHT_KEY, WeightUnit,
)


KNOWN_USERS: dict[str, Profile] = {
    'alice': Profile(sex=Sex.Female, age=34, height_m=1.65),
    'bob':   Profile(sex=Sex.Male,   age=43, height_m=1.78),
}


async def resolve_user(weight_kg: float) -> Profile | None:
    """Pick the user whose typical weight is closest to the reading.

    Real implementations would do a DB lookup, talk to a Home
    Assistant entity, etc. The callback is async so I/O won't block
    the BLE event loop.
    """
    if weight_kg < 70:
        return KNOWN_USERS['alice']
    return KNOWN_USERS['bob']


def notification_callback(data: ScaleData):
    print(f"weight={data.measurements[WEIGHT_KEY]} kg")


async def main():
    scale = RenphoESCS20MScale(
        'XX:XX:XX:XX:XX:XX',
        notification_callback,
        WeightUnit.KG,
        profile=resolve_user,        # ← user-detection mode
    )
    await scale.async_start()
    await asyncio.sleep(30)
    await scale.async_stop()


asyncio.run(main())

The scale firmware will not start a measurement without a profile reply, so the library always sends one in response to the scale's 0x21 05 ff profile request. In detection mode it sends a bootstrap profile with algorithm=0x00 (body fat calculation disabled) so the measurement starts; on the first stable weight frame it awaits resolve_user(weight) and writes the returned profile to the scale, which then computes body fat and emits the stable-with-metrics frame. Returning None from the resolver leaves the bootstrap profile in place — the scale stays in weight-only mode for that session.

The resolver must return faster than the scale's internal body fat commit window — empirically about 2 seconds after the first stable frame. If it doesn't, the scale will finalize the measurement against the bootstrap profile (no body fat) before your resolved profile lands. If the BLE session ends while the resolver is still in flight, the library cancels the resolver task to avoid leaking work.

API Reference

  • Profile(sex, age, height_m, athlete=False, algorithm=0x04) — user-profile inputs the scale needs to compute body fat on-device. See Profile's docstring for the wire semantics of each field.
  • ProfileResolver — type alias for the async callback used in user-detection mode: Callable[[float], Awaitable[Profile | None]]. Receives the first stable weight in kg and returns the Profile to write (or None to skip).
  • RenphoESCS20MScale(address, callback, display_unit, *, profile, …) — BLE scale client. The profile argument is one of:
    • a Profile (fixed-user mode),
    • a ProfileResolver (user-detection mode),
    • None (weight-only mode, default).
  • callback passed to RenphoESCS20MScale: receives ScaleData only when the scale emits a final stable-with-metrics frame. In user-detection mode, the earlier stable frame is used only to trigger the profile resolver.
  • battery_level — last successfully-read battery percentage (int | None). May be None until first successful read.
  • firmware_revision — last successfully-read firmware revision string (str | None). May be None until first successful read or when response is empty.
  • ScaleData — dataclass passed to the notification callback. Fields: name, address, display_unit, and measurements (a dict keyed by the constants in the next section).
  • BluetoothScanningModeACTIVE (default) / PASSIVE. Passive scanning uses less power on platforms that support it.
  • BodyMetrics(weight_kg, height_m, age, sex, body_fat_percentage) — derives body-composition metrics from a stable reading. Call it from the notification callback once a Profile is known. No athlete parameter: by the time a body fat value reaches this class, the scale's firmware has already applied the athlete adjustment.
  • calculate_body_fat(weight_kg, height_m, age, sex, resistance, *, algorithm=0x04, athlete=False) — off-scale approximation of the on-device body fat formulas (algorithms 0x03 and 0x04 only). Useful for recomputing body fat after the fact when a slow user-detection lookup misses the scale's commit window.
  • SexMale / Female.
  • WeightUnitKG, LB, ST, ST_LB.
  • build_user_profile_command(...) — raw command builder for the guest-mode user-profile frame the scale expects. Most callers should construct a Profile and let RenphoESCS20MScale call this builder; use it directly only if you need to bypass the protocol state machine.

Body fat algorithm (Profile.algorithm)

Selects which on-device body fat formula the scale runs. Most callers should leave this at the default.

  • algorithm=0x04 (default) and algorithm=0x03 are the two formulas Renpho's app selects from in normal use. The selection appears to depend on user region.
  • algorithm=0x00 disables the on-scale body fat calculation entirely; the scale streams weight only. This is what the library uses internally during user-detection bootstrap.
  • Other values (0x01, 0x02, 0x05, 0x06) are accepted by the scale but don't seem to be used by Renpho's app and aren't validated against it — treat them as experimental.

Profile.athlete=True is independent of algorithm: it switches the firmware to its athlete-tuned curve regardless of which formula is selected.

The library also ships an off-scale approximation of algorithms 0x03 and 0x04 via calculate_body_fat() — useful when the scale's body fat commit window closes before a slow user-detection lookup resolves. The other algorithms aren't currently approximated in software.

Measurement-dict keys (constants)

  • WEIGHT_KEY ("weight") — kg
  • BODY_FAT_KEY ("body_fat") — % (only on stable-with-metrics frames)
  • RESISTANCE_1_KEY, RESISTANCE_2_KEY ("resistance_1", "resistance_2") — bioelectrical impedance in ohms (only on stable-with-metrics frames; the two readings are typically within a couple of ohms of each other and either can be fed to calculate_body_fat()).
  • BodyMetrics exposes its own snake_case attributes (body_mass_index, basal_metabolic_rate, …).

App-matching conventions

The Renpho app applies a few non-obvious transformations to profile data before running the body fat calculation. This library matches some of them automatically and intentionally diverges from one:

  1. Height precision: library passes through; app truncates to whole cm. The Renpho app truncates a 170.7 cm profile to 170 cm before running the body fat calculation. This library passes the user's exact height_m through to the scale (rounded to the nearest mm), giving slightly more precise body fat from the scale's on-device curve.
    • If you want to reproduce the Renpho app's displayed values exactly (for cross-checking), pre-truncate the call site: height_m = int(actual_cm) / 100.
  2. Age is birthday-aware. For a profile whose UI age shows N, the app uses N if the birthday has already occurred this year, else N − 1. Profile.age is a plain integer — callers wanting to match the app should compute this themselves before constructing the Profile.

Compatibility

  • Python 3.11+
  • bleak 2.x (bleak>=2.0.0,<3.0.0)
  • Tested on macOS (Apple Silicon)
  • Linux via BlueZ should work through the standard bleak backend but is unverified
  • Compatibility with Windows is unknown

Troubleshooting

On Raspberry Pi (and possibly other Linux machines using BlueZ), if you encounter a org.bluez.Error.InProgress error, try the following in bluetoothctl:

power off
power on
scan on

(See https://github.com/home-assistant/core/issues/76186#issuecomment-1204954485)

Support the Project

If you find this unofficial project helpful, consider buying me a coffee! Your support helps maintain and improve this library.

Buy Me A Coffee

License

This project is licensed under the MIT License - see the LICENSE file for details.

Disclaimer

This is an independent project developed by the community. It is not endorsed by, directly affiliated with, maintained, authorized, or sponsored by Renpho, or any of their affiliates or subsidiaries. All product and company names are the registered trademarks of their original owners. The use of any trade name or trademark is for identification and reference purposes only and does not imply any association with the trademark holder of their product brand.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

renpho_escs20m-0.1.2.tar.gz (20.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

renpho_escs20m-0.1.2-py3-none-any.whl (20.9 kB view details)

Uploaded Python 3

File details

Details for the file renpho_escs20m-0.1.2.tar.gz.

File metadata

  • Download URL: renpho_escs20m-0.1.2.tar.gz
  • Upload date:
  • Size: 20.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for renpho_escs20m-0.1.2.tar.gz
Algorithm Hash digest
SHA256 9a15b9195a580238663591cadbb96c40ed751aff116924deccb64176c7281974
MD5 350978e328d0de201b1d78034c318d4e
BLAKE2b-256 538a9bafeb2989a6c17e3869d143f96d4e277d0b0f34568212ed30f912fcc058

See more details on using hashes here.

Provenance

The following attestation bundles were made for renpho_escs20m-0.1.2.tar.gz:

Publisher: ci-cd.yml on ronnnnnnnnnnnnn/renpho-escs20m

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file renpho_escs20m-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: renpho_escs20m-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 20.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for renpho_escs20m-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 fccb57509348ceb208dd946843c686f935cfeddb5258d2b2a276bb84792b8090
MD5 e446175cbe9176fe3e7267d803849df3
BLAKE2b-256 e33385d66e3e0fc7cccc5fae47f1b8f8e6a567e440f7584db8b66c13b0457917

See more details on using hashes here.

Provenance

The following attestation bundles were made for renpho_escs20m-0.1.2-py3-none-any.whl:

Publisher: ci-cd.yml on ronnnnnnnnnnnnn/renpho-escs20m

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page