Skip to main content

Renpho ES-CS20M BLE

PyPI Python versions CI License: MIT

This package provides an unofficial interface for interacting with Renpho's ES-CS20M scale (and other scales that share the same QN-series protocol, including some non-Renpho ones) over Bluetooth Low Energy. It also has experimental support for 0x55aa GATT scales and experimental weight-only support for a broadcast-only (0xaabb) subvariant. See the Device compatibility section for the current list of confirmed-working models.

Disclaimer: This is an unofficial, community-developed library. It is not affiliated with, endorsed by, or connected to Renpho, its parent companies, subsidiaries, or affiliates. The official Renpho website can be found at https://www.renpho.com. "Renpho", "ES-CS20M", and other model names referenced here, along with related marks, emblems, and images, are property of their respective owners. Use of any trade name or trademark is for identification and reference purposes only and does not imply any association with the trademark holder.

Health & safety note: Body-composition scales measure by passing a low electrical current through the body. This project does not support the manufacturers' zero-current, pregnancy or hold-baby modes, and a mode enabled in the manufacturer's app is not guaranteed to be in effect while the scale is used through this library. Use may be unsuitable for anyone who might be pregnant or who has a pacemaker or other implanted medical device. Body-composition values are bioimpedance estimates, not clinical measurements, and this library is not a medical device; do not use its output for medical decisions.

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 mass, body water %, skeletal muscle %, muscle mass, bone mass, protein %, BMR, and a body fat % passthrough.
  • On models that compute a full body-composition panel on-device (e.g. the R-MSB01), the scale's own metrics — BMI, body water, muscle and bone mass, visceral fat, BMR, protein and more — are delivered merged into the measurement callback.
  • Experimental: weight-only support for the broadcast-only ES-CS20M subvariant via RenphoAABBScale (no body composition — see Device compatibility).

Installation

pip install renpho-escs20m

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

Device compatibility

The library speaks up to three Renpho BLE protocols; what a scale supports depends on which one its hardware uses:

Protocol support at a glance

Protocol Transport Status Features
QN-series GATT ✅ Supported Weight, impedance, body-composition metrics, display-unit control
0x55aa (basic) GATT 🔬 Experimental Weight and impedance, display-unit control
0x55aa (extended) GATT 🔬 Experimental Weight and impedance, display-unit control; guest profile sent to the scale
0xaabb Broadcast 🔬 Experimental Weight only (display unit observed, not settable)

Identifying your scale

Which protocol a scale speaks doesn't track the marketed model name: several Renpho models share the QN-series hardware, while some ES-CS20M hardware revisions speak a different protocol (0x55aa, or the broadcast-only 0xaabb). The reliable discriminator is the HVIN (Hardware Version Identification Number) printed on the regulatory sticker on the back of the scale, including its trailing revision code (e.g. …MA2 vs …MB1 vs …MB2 vs …MN). Some stickers don't print HVIN as a separate field — in that case the same identifier is usually embedded as the trailing portion of the FCC ID (e.g. FCC ID 2A26P-ESCS20MA2 → device code ESCS20MA2). The tables below list both, so you can match on either.

Confirmed-working (QN-series):

Marketed model HVIN FCC ID
ES-CS20M ESCS20MA2 2A26P-ESCS20MA2
ES-CS20M ESCS20MN 2A26P-ESCS20MN
ES-CS20M — 2A26P-ESCS20M
ES-26M ESCS20MA2 2A26P-ESCS20MA2
ES-30M ES30MA2 2A26P-ES30MA2
ES-32MD ESCS20MA2 2A26P-ESCS20MA2
R-MSB01 — 2A26P-RMSB01
  • R-MSB01 — a later hardware revision of the same CS20 platform. Extended-flavor (on-device body fat via a BLE-pushed profile), FFF0 GATT layout, identical handshake and field offsets to ES-CS20M — the one difference is its extended measurement frame is 15 bytes, not 14, with one extra byte before the checksum that the parser does not read. It also computes the full body-composition panel on-device and sends it in two further frames after each measurement, which the library reports via the on-device metrics keys described under Measurements. Its resistance fields seem to be obfuscated on the wire, so they are omitted from the measurement callback rather than reported as ohms — body composition comes from the scale itself here, so nothing is lost.

Experimental:

Marketed model HVIN FCC ID Protocol
Arboleaf CS20M — 2ANDX-CS20M QN-series (FFE0 GATT)
ES-CS20M — 2APXUES-CS20M 0xaabb (broadcast)
ES-CS20M ESCS20MB1 2A26P-ESCS20MB1 0x55aa (basic)
ES-26BB-B ES26BBB ? 0x55aa (basic)
R-A012 — 2A26P-RA012N 0x55aa (basic)
R-A016 — 2A26P-RA016 0x55aa (basic)
ES-CS20M ESCS20MB2 2A26P-ESCS20MB2 0x55aa (extended)
  • Arboleaf CS20M — QN-series hardware ships the same wire protocol on two GATT service layouts, and the library handles both: the FFF0 layout used by the Renpho models above, and the FFE0 layout seen on some other QN scales, like this one. Full feature set via RenphoQNScale.
  • ES-CS20M (FCC ID 2APXUES-CS20M) — a non-connectable subvariant: it broadcasts weight in its BLE advertisements rather than connecting over GATT, using a different (0xaabb) protocol. Supported via RenphoAABBScale. Impedance is not reported (for body composition calculations see Broadcast variant). The display unit can be observed but not set.
  • 0x55aa basic flavor (ES-CS20MB1, ES-26BB-B, R-A012, R-A016) — scales that stream weight and bioimpedance over GATT notify characteristic 0x2A10 on vendor service 0x1A10. Supported via Renpho55AAScale.
  • 0x55aa extended flavor (ES-CS20M ESCS20MB2) — the scale computes body composition on-device from a profile sent over BLE, and will not release a final at all without one. The library sends a guest profile (no user is ever registered on the scale), reports weight and impedance, and leaves the on-device BMI/body-fat unreported: they reflect whatever profile the scale held when the measurement committed, which a profile written mid-weigh-in cannot guarantee. Compute body composition off-scale exactly as for the basic flavor. The client picks this flavor up from the scale's advertisement; model_id (0x0031 / 0x0030) only needs passing to override that.

The pattern so far: marketed model name is unreliable, but the HVIN — and specifically its revision suffix (A2, B1, B2, N…) — tracks the actual hardware and apparently also the protocol. If your Renpho scale HVIN ends in A2, B1, B2, or N, this library will likely work with it; if it ends in some other suffix, try it out to see if it works and report back on the issue tracker.

This library may also work with other QN-Scale varieties utilizing the same protocol (on either GATT layout), including non-Renpho ones. Feel free to report compatibility results on the issue tracker.

Reporting a compatibility result

If your scale isn't in any of the tables above, open an issue at github.com/ronnnnnnnnnnnnn/renpho-escs20m/issues with:

  • Marketed model (e.g., ES-CS20M)
  • HVIN from the back-of-device sticker (including the revision suffix)
  • Whether the library actually drives the scale correctly (live weight notifications, body fat values, etc.)

The library itself doesn't gate or warn on compatibility at runtime — it'll attempt the handshake against any device. This section is the canonical compatibility record.

Quick start

Weight only (no body fat)

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


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


async def main():
    scale = RenphoQNScale(
        '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, RenphoQNScale,
    ScaleData, Sex, WEIGHT_KEY, WeightUnit,
)


PROFILE = Profile(
    sex=Sex.Male,
    age=35,
    height_m=1.80,
    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 = RenphoQNScale(
        '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, RenphoQNScale, 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 = RenphoQNScale(
        '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.

0x55aa variant

Basic-flavor 0x55aa scales (e.g. ES-CS20MB1, R-A012, ES-26BB-B, R-A016) stream weight and resistance over GATT notifications with no profile write required:

import asyncio
from renpho_escs20m import (
    Renpho55AAScale,
    RESISTANCE_1_KEY,
    ScaleData,
    WEIGHT_KEY,
)


def notification_callback(data: ScaleData):
    weight = data.measurements.get(WEIGHT_KEY)
    resistance = data.measurements.get(RESISTANCE_1_KEY)
    if resistance is not None:
        print(f"weight={weight} kg  resistance={resistance} Ω")
    else:
        print(f"weight={weight} kg")


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


asyncio.run(main())

Extended-flavor units (ESCS20MB2, model ids 0x0031/0x0030) additionally take a profile. The flavor is learned from the scale's advertisement, so model_id is optional. Pass either a fixed profile (single user), an async resolver called once per weigh-in with the settled weight (several users), or nothing (weight only):

import datetime
from renpho_escs20m import Renpho55AAScale, Sex, X55AAProfile

profile = X55AAProfile(
    sex=Sex.Female,
    birthday=datetime.date(1988, 12, 6),
    height_m=1.57,
    athlete=False,
    algorithm=0x03,  # or 0x04; same ids as calculate_body_fat
    last_weight_kg=84.4,  # optional: the user's previous reading, as the app sends
)


async def resolve(weight_kg: float) -> X55AAProfile | None:
    return profile if 80 < weight_kg < 90 else None  # None → placeholder profile


scale = Renpho55AAScale(
    'XX:XX:XX:XX:XX:XX',
    notification_callback,
    model_id=0x0031,  # optional: learned from the advertisement when omitted
    profile=resolve,
)

Notes: the profile is written in the scale's guest channel, so no user is created on the scale and the official app is unaffected. The client also sets the scale's clock at connect (the scale derives age from the birth date and its clock for the numbers on its own display). If a resolver returns None, raises, or takes longer than 2 s, a placeholder profile is sent so the reading is not lost; the callback then carries weight and impedance as usual. On the scale's own display, BMI and body fat are only reliable in fixed-profile mode, where the profile is on the scale before you step on. In resolver mode the profile is written once the weight settles, which may be after the scale has already computed its numbers, so the display may show values for the profile it held before (an earlier resolver answer, or the placeholder), or none at all. The callback is unaffected either way: the library never reports the scale's own numbers. If the scale powers off without sending a final after the weight settled, the settled weight is reported without impedance.

Broadcast variant

The broadcast-only 0xaabb subvariant uses a different client, RenphoAABBScale — no Profile, no unit control, weight only:

import asyncio
from renpho_escs20m import RenphoAABBScale, ScaleData, WEIGHT_KEY


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


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


asyncio.run(main())

Estimating body composition. This scale transmits no impedance, so RenphoAABBScale reports weight only. You can still produce body-composition metrics by calling calculate_body_fat() / BodyMetrics with a fixed synthetic impedance (~500 Ω): the algorithm is near-impedance-independent in that band — verified <1 percentage-point change in body fat across R = 300–900 Ω — so the exact value barely matters, and ~500 reproduces pretty closely what the official Renpho app shows.

Protocol detection

detect_protocol() classifies a BLE advertisement (local name, manufacturer data, and address) as ScaleProtocol.QN, ScaleProtocol.X55AA, ScaleProtocol.AABB, or None if it isn't a recognized scale. Pair it with SCALE_CLASSES to pick the right client class without hardcoding if/else branches:

from renpho_escs20m import SCALE_CLASSES, detect_protocol

protocol = detect_protocol(local_name, manufacturer_data, address)
if protocol is not None:
    scale_cls = SCALE_CLASSES[protocol]
    scale = scale_cls(address, notification_callback, ...)

Frame layouts (manufacturer-data value, company ID already stripped):

  • QN (company ID 65535): [0:2] model identifier, 16-bit big-endian; [2:4] model-dependent constants; [4] pending stored-record count, which varies with device state; [5:11] device MAC address, little-endian.
  • 0x55aa (company ID 0x1A10): [0:2] fixed 00 04 prefix; [2:4] model identifier, 16-bit big-endian (0x0003 = basic flavor, 0x0030/0x0031 = extended flavor — Renpho55AAScale reads it from here itself); [4:10] device MAC address, forward byte order; [10:] trailing bytes.
  • AABB (company ID 65535): [0:2] 0xAABB magic; [2:8] device MAC address, forward byte order; [8:] protocol payload.

Known QN model identifiers, observed in real advertisement captures:

Identifier Notes
0x095B "Renpho-Scale", FF:04:00 OUI
0x099B "QN-Scale", FF:04:00 OUI
0x09E9 "QN-Scale", FF:03:00 OUI
0x0216 "QN-Scale", D8:0B:CB OUI
0x0C77 "Renpho-Scale", FF:05:00 OUI (R-MSB01)

Advertisements with an unrecognized QN model identifier still classify via a name/address fallback matcher and log a warning — reporting that warning (or an incompatible scale on the issue tracker) is how the identifier registry grows.

API reference

QN variant

  • RenphoQNScale(address, callback, display_unit, *, profile=None, scanning_mode=BluetoothScanningMode.ACTIVE, …) — BLE scale client. The profile argument is one of:

    • a Profile (fixed-user mode),
    • a ProfileResolver (user-detection mode),
    • None (weight-only mode, default).

    The profile modes only apply to the QN extended flavor (e.g. HVIN ESCS20MA2) — the variant where the scale computes body fat on-device from a profile sent over BLE. The basic flavor (HVIN ESCS20MN) takes no profile over BLE, so the argument is ignored there: readings always include weight plus raw impedance, and body fat is computed off-scale via calculate_body_fat(). In all cases "weight-only mode" refers to what the scale computes and displays — it never restricts what the library reports: both flavors deliver raw impedance in every mode, and it is passed through — except on models whose resistance fields are obfuscated on the wire, where the values are withheld (see Measurements).

    clear_stored_measurements=True (default False) drains the scale's store of offline measurements — readings taken while nothing was connected — once per session. Receiving a stored reading deletes it from the scale (the protocol has no separate delete command), so enabling this hides those readings from any other client: leave it off if you also sync the scale with the official Renpho app. Drained readings are logged at debug level and discarded for now. Each flavor is queried with its own command form.

    Additional keyword arguments (adapter, cooldown_seconds, max_connect_attempts, bleak_scanner_backend, logger) are available for advanced use — see the class docstring. RenphoESCS20MScale remains importable as a backward-compatible alias for RenphoQNScale.

  • callback (passed to RenphoQNScale) — invoked once per measurement, on the final frame the scale emits (the stable-with-metrics frame on the extended flavor; the status-0x01 final frame on the basic flavor). On recognized panel-sending models (e.g. the R-MSB01) the invocation comes a moment later, once the scale's body-composition panel has arrived and been merged in — see Measurements. In user-detection mode, the earlier stable frame is used only to trigger the profile resolver and does not reach the callback. Within the frame, ScaleData.measurements always contains WEIGHT_KEY; BODY_FAT_KEY and the two RESISTANCE_*_KEY entries are present only when the scale actually produced non-zero values for them. BODY_FAT_KEY requires the extended flavor and a real profile: it is absent in weight-only mode, in user-detection mode if the resolver returned None, and any time algorithm=0x00. Impedance is reported by both flavors in every mode — the impedance pass runs even under the bootstrap (algorithm=0x00) profile — but withheld on obfuscated-resistance models. The basic flavor never produces BODY_FAT_KEY — compute body fat from RESISTANCE_1_KEY with calculate_body_fat().

  • scale.battery_level — last successfully-read battery percentage (int | None). May be None until first successful read. Reliability caveat: on at least one observed unit (firmware V10.0) the scale reported a static 100 and didn't appear to decrement it as the batteries drained — reading 100% even on cells weak enough to need replacing — and exposed no other battery source over BLE. It's unknown whether other hardware revisions or firmware behave the same way, so treat a steady 100% as possibly unreliable rather than assuming it; the value is reported as-is and may be accurate on your device.

  • scale.firmware_revision — last successfully-read firmware revision string (str | None). May be None until first successful read or when response is empty.

  • scale.diagnostic_info — JSON-safe dict for bug reports, available on every scale class. The common keys are scale_class, protocol, model_code, model_label ("unknown" for an unregistered identifier, None when none has been seen), flavor, advertisement and trace.

    • advertisement is a snapshot (timestamp, name, RSSI, service UUIDs, manufacturer data) of the scale's latest advertisement, refreshed even while a cooldown is open.
    • trace is what recently crossed the wire, in order: entries with dir ("rx"/"tx"), data (hex) and t (seconds), interleaved with the library's own event entries ("session start", "disconnected", "session setup failed: …", the profile resolver's outcome). A "session start" entry restarts t and carries wall-clock time. Consecutive frames that differ only in the weight collapse into one entry with count, last and t_last. The buffer holds the last 120 entries across sessions, so a tap-to-wake or a reconnect does not erase the weigh-in before it.
    • Frames are verbatim: they carry the measurements, and any profile sent to the scale (sex, age, height). If your dumps end up posted in public, you can call scale.get_diagnostic_info(mask_profiles=True) instead of the property: the personal bytes of outgoing profile frames are then masked too (the frame, its algorithm byte and its timing stay — when a profile was written is what the trace is for). Measurements are never masked. By default only the scale's MAC is masked — the device-specific half of every echo of it (mask_mac_echo); the OUI is kept because models are told apart by it.
    • model_label(protocol, model_code) gives the same description for a code obtained elsewhere.

    On this variant the model identifier comes from the advertisement (on some models only from the scan response, which a passive scanner never relays), while flavor is only learned from a session's frames. It adds transport ("fff0" or "ffe0", the GATT layout the scale exposed) and vendor_byte (the per-device byte the scale stamps on its frames). These and flavor survive disconnects, since a dump is usually taken after the scale has gone back to sleep.

  • BluetoothScanningMode — ACTIVE (default) / PASSIVE, passed via the scanning_mode kwarg. PASSIVE only takes effect on Linux (BlueZ); other platforms fall back to active.

0x55aa variant (experimental)

  • Renpho55AAScale(address, callback, display_unit=WeightUnit.KG, *, model_id=None, profile=None, clear_stored_measurements=False, scanning_mode=BluetoothScanningMode.ACTIVE, …) — client for the 0x55aa GATT variant (LeFu hardware). It subscribes to notification characteristic 0x2A10 on vendor service 0x1A10.
  • model_id is the identifier the scale advertises, and selects the flavor: 0x0030/0x0031 are the extended flavor, anything else the basic one. It is optional — learned from the advertisement when omitted; pass it to override.
  • profile applies to the extended flavor only, where the scale releases no final without a profile on it. It takes an X55AAProfile, an async resolver called once per weigh-in with the settled weight, or None. The library always writes it in the scale's guest channel, so no user is registered on the scale. With None it writes a placeholder that asks the scale to skip its bioimpedance pass, so the reading and the scale's own display are both weight alone — unlike the QN client, whose weight-only mode still reports impedance. To get impedance without body composition, pass a profile: the scale's own numbers are discarded in every mode anyway.
  • age_on(birthday, today) — whole years from birthday to today, birthday-aware, for feeding calculate_body_fat() from an X55AAProfile (which carries a date of birth rather than an age).
  • ScaleData.measurements contains WEIGHT_KEY (always kg) plus RESISTANCE_1_KEY (ohms) when bioimpedance produces a non-zero reading.
  • Body fat: the basic flavor computes nothing on-device; the extended flavor does, but from whatever profile the scale held when the measurement committed, so those numbers are deliberately not reported. Compute it off-scale from resistance_1 with calculate_body_fat() on both flavors.
  • ScaleData.display_unit reflects the unit the scale reports in its status frames, i.e. what the display actually shows.
  • When the scale is in zero-current (pregnancy) mode — a setting the official app stores on the scale — the bioimpedance pass is skipped and the reading is delivered as weight only. On the extended flavor, a scale that powers off without ever releasing a final still reports the settled weight, alone, a second later; a final arriving inside that second wins.
  • scale.diagnostic_info — as on the QN variant (common keys, advertisement snapshot, trace). Here model_code is the identifier the client runs on — adopted from the advertisement, or the model_id you passed — and flavor follows from it, so both are known before any connection. The trace records raw notifications, so an extended unit's fragment headers show; each tx entry has a note naming what was written (which is how a resolved profile is told from the placeholder), and the reason a placeholder went out (the resolver returned None, raised, timed out or was cancelled) or a refused write appears as an event. A profile frame carries the sex, date of birth and height; get_diagnostic_info(mask_profiles=True) masks those and keeps the slot, last weight and flags.
  • Stored offline records (basic 0x15, extended 0x19) are logged and discarded, never reported as live readings. clear_stored_measurements=True acknowledges each one (basic 0x95, extended 0x99); on the R-A016 a single acknowledgement cleared the whole store, and on the extended flavor the delete scope is unverified. Extended records are released per user slot only to a session presenting that slot's profile, so a guest session has not been observed to receive any. It is left off by default so the official app can collect those readings.

Broadcast variant (experimental)

  • RenphoAABBScale(address, callback, *, scanning_mode=…, adapter=…, bleak_scanner_backend=…, logger=…) — client for the non-connectable 0xaabb ES-CS20M subvariant. It never opens a GATT connection; it reads weight straight from the scale's BLE advertisements. Differences from RenphoQNScale:
    • ScaleData.measurements contains only WEIGHT_KEY (always kg).
    • ScaleData.display_unit reflects the unit the scale's LCD is showing (observed from the advertisement). It is read-only — the scale cannot be told to change units, and assigning display_unit is ignored.
    • No profile, no body composition (this scale does no impedance/BIA), and no battery_level / firmware_revision.
    • Weight-only, and validated against captured advertisements rather than live hardware.
    • scale.diagnostic_info carries the common keys only: this variant advertises no model identifier and has no flavors, so model_code, model_label and flavor stay None. With no session, its trace is the advertisement burst itself (dir "adv"), collapsed by status byte.

Extending the library

  • RenphoScale / GattScale / AdvertisementScale — the abstract base classes the concrete clients subclass (RenphoScale holds the scanner lifecycle; GattScale and AdvertisementScale are the connection-based and advertisement-based transports). Exported for adding new protocol variants.

Profiles

  • Profile(sex, age, height_m, athlete=False, algorithm=0x04) — user-profile inputs the scale needs to compute body fat on-device (extended flavor; the basic flavor takes no profile over BLE — feed the same inputs to calculate_body_fat() instead). 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). Extended flavor only — the basic flavor never requests a profile, so the resolver is never invoked for it.

Measurements

  • ScaleData — dataclass passed to the notification callback. Fields: name, address, display_unit, and measurements (a dict keyed by the constants below).

  • WeightUnit — KG, LB, ST, ST_LB.

  • Measurement-dict keys (constants importable from renpho_escs20m):

    • WEIGHT_KEY ("weight") — kg
    • BODY_FAT_KEY ("body_fat") — % (extended flavor only, and only when the scale ran its on-device body fat calculation)
    • RESISTANCE_1_KEY, RESISTANCE_2_KEY ("resistance_1", "resistance_2") — bioelectrical impedance in ohms (present on final frames when non-zero; the two readings are typically within a couple of ohms of each other and either can be fed to calculate_body_fat()). Absent on models that obfuscate these fields, where the wire values are not ohms.
  • On-device body composition, reported only by models that compute it themselves (see below):

    • BMI_KEY, BODY_SCORE_KEY ("bmi", "body_score") — ratings
    • BODY_WATER_KEY, PROTEIN_KEY, SUBCUTANEOUS_FAT_KEY, SKELETAL_MUSCLE_KEY — %
    • MUSCLE_MASS_KEY, BONE_MASS_KEY, FAT_FREE_MASS_KEY — kg
    • BMR_KEY — kcal/day
    • VISCERAL_FAT_KEY, BODY_AGE_KEY, BODY_SHAPE_KEY — the scale's own unitless ratings. BODY_AGE_KEY is the scale's value; the Renpho app displays a metabolic age it derives separately, so the two differ slightly.

    The scale sends its body-composition panel a moment after the final measurement frame. On recognized panel-sending models the library holds the reading briefly and delivers it once, complete — one weigh-in, one callback, panel included. If the panel never finishes (lost frames, early disconnect), the reading is still delivered within about a second, just without the missing fields — it is never lost. A model that sends these frames without being recognized gets its reading immediately as usual, minus the panel: the frame layout is only trusted on verified models, so the library logs a warning asking for the model to be reported rather than guessing.

Body composition

  • 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. Exposes these snake_case attributes:
    • body_mass_index — BMI
    • body_fat_percentage — passthrough of the constructor input
    • fat_free_mass (kg)
    • body_water_percentage
    • skeletal_muscle_percentage
    • bone_mass (kg)
    • muscle_mass (kg)
    • protein_percentage
    • basal_metabolic_rate (kcal/day, integer)
  • 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). Complements BodyMetrics: BodyMetrics takes an already-computed body fat value as input, while calculate_body_fat computes one from raw impedance. The typical pairing is to feed calculate_body_fat's output into BodyMetrics when a slow user-detection lookup misses the scale's commit window and body fat needs to be recomputed from RESISTANCE_1_KEY after the fact.

Low-level

  • 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 RenphoQNScale 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 measurement itself still runs; the scale just computes and displays no body fat. This is what the library uses internally in weight-only mode and 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.

App-matching conventions

The Renpho app applies a few non-obvious transformations to profile data before running the body fat calculation. The library diverges from one and leaves the other to the caller:

  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.

Platform compatibility

  • Python 3.11+
  • bleak 2.x or 3.x (bleak>=2.0.0,<4.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 home-assistant/core#76186 (comment) for context.)

Acknowledgments

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

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 its 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.

This software is provided as is, without warranty of any kind.

License

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

Release files for renpho-escs20m 0.8.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for renpho-escs20m 0.8.0
File Size Uploaded
renpho_escs20m-0.8.0.tar.gz 90.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for renpho-escs20m 0.8.0
File Interpreter ABI Platform
renpho_escs20m-0.8.0-py3-none-any.whl Python 3 none any Details

Total release size: 175.9 kB

Release files / renpho_escs20m-0.8.0.tar.gz

Download URL renpho_escs20m-0.8.0.tar.gz
Size 90.5 kB
Tags Source
SHA-256 checksum
How to use checksums
2d7d35584ad14beb4857e02fa48ff0f6d503e2a1a78bfeabd2da251cb51af2a6
BLAKE2b-256 checksum
How to use checksums
7a6571398030707e54d0e2b6c4e4453b35ae4174a221ac84aeb9035460e53de1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.

Transparency log

Release files / renpho_escs20m-0.8.0-py3-none-any.whl

Download URL renpho_escs20m-0.8.0-py3-none-any.whl
Size 85.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
466904550024a4953b6a125f31edc7bd09cf0973f0a89afa81d6a1ec3291c854
BLAKE2b-256 checksum
How to use checksums
aa27d471f51d97b25d75cd56d45e96c695b7343498232fbf8110fb1cf1f44b1b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.8.0 This release

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.2

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.3

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page