Skip to content

Exporters ​

Every reading goes to all enabled exporters in parallel. Exporters under global_exporters are shared by all users, and each user can add their own (per-user exporters). The setup wizard adds and tests them.

A run reports an error only when every exporter fails. Only targets that can record a past measurement get retries and weigh-ins replayed from the scale's memory, see Historical readings and the retry setting.

TargetWhat it does
Garmin ConnectUploads weight and body composition, no phone app
MQTTHome Assistant auto-discovery sensors, LWT
InfluxDBTime-series database (v2 and v3)
WebhookAny HTTP endpoint (n8n, Make, Zapier, custom APIs)
NtfyPush notification to phone or desktop
TelegramMessage to a Telegram chat
File (CSV/JSONL)Appends readings to a local file
StravaWeight in your Strava athlete profile
Intervals.icuWeight and body fat to Intervals.icu wellness
RunalyzeWeight and body composition to Runalyze metrics
WgerWeight and body composition to a Wger instance
HealthLogWeight and body composition to a HealthLog instance
DreeveWeight to a self-hosted Dreeve instance

MQTT, Webhook, File JSONL and InfluxDB also receive the skeletal muscle percentage and the segment impedances, for weigh-ins that carry them. The Renpho R-MSC04 sends both, and the Arboleaf CS10E on the 20-byte QN dialect sends the skeletal muscle percentage. Ntfy and Telegram show only the skeletal muscle percentage; CSV and the other targets get the same fields for every scale.

Garmin Connect ​

Uploads weight and body composition to Garmin Connect through a Python helper that reuses cached login tokens. A standalone install needs Python 3.12 or newer; the Docker image and the Home Assistant add-on include it.

FieldRequiredDefaultDescription
emailYes(none)Garmin account email
passwordYes(none)Garmin account password
token_dirNo~/.garmin_tokensDirectory for cached login tokens. A relative path is relative to the directory of config.yaml; the wizard writes ./garmin-tokens
weight_onlyNofalseUpload the weight alone, leaving every derived metric unset
skip_metabolic_ageNofalseUpload every metric except metabolic age
upload_timeout_secNo180Seconds one upload attempt may take before it is killed (10-900). Three attempts are made, 1 s and 2 s apart
yaml
global_exporters:
  - type: garmin
    email: '${GARMIN_EMAIL}'
    password: '${GARMIN_PASSWORD}'

Authentication ​

Authenticate once; the tokens are cached and reused. The setup wizard does it for you. By hand:

bash
ble-scale-sync setup-garmin --all-users   # every Garmin user in config.yaml
ble-scale-sync setup-garmin --user Alice  # one user, by name or slug
# from a clone: npm run setup-garmin -- --all-users

Both read the email, password and token_dir from config.yaml. Without arguments, setup-garmin reads GARMIN_EMAIL and GARMIN_PASSWORD from the environment or .env and writes to TOKEN_DIR, otherwise to ~/.garmin_tokens. If the account uses MFA, the script asks for the code.

In Docker, mount config.yaml and the token directories that config.yaml names. -e passes GARMIN_EMAIL and GARMIN_PASSWORD from your shell, for a config.yaml that references them:

bash
mkdir -p garmin-tokens
docker run --rm -it \
  -v ./config.yaml:/app/config.yaml \
  -v ./garmin-tokens:/app/garmin-tokens \
  -e GARMIN_EMAIL -e GARMIN_PASSWORD \
  ghcr.io/kristianp26/ble-scale-sync:latest setup-garmin --all-users

The Home Assistant add-on signs in on its first start.

For an account with MFA see Sign in to Garmin with MFA.

Weight only ​

weight_only: true uploads the weight and leaves BMI, body fat, water, bone mass, muscle mass, visceral fat, physique rating, metabolic age and BMR unset. Use it when you trust the scale's weight but not its impedance estimates. Garmin Connect still shows a BMI it computes from your profile height. Other exporters are not affected.

Skip metabolic age ​

skip_metabolic_age: true uploads everything except metabolic age. The body age sent by a QN scale with a 19-byte scale-info frame, or by the Arboleaf CS10K, is skipped too. Garmin's own Fitness Age is not affected.

Upload timeout ​

If the log shows Python uploader timed out three times for a measurement that uploads fine by hand later, Garmin Connect was slow. Raise upload_timeout_sec (up to 900). A Garmin outage then takes about three times the timeout to give up, and in continuous mode the next scan cycle and the Ntfy or Telegram summary wait as long.

Troubleshooting ​

Garmin can block logins from cloud and VPN addresses. If authentication fails there, authenticate on another network and copy the token directory to the machine that runs the app.

MQTT ​

Publishes each weigh-in as JSON to an MQTT broker, with Home Assistant auto-discovery on by default. The Home Assistant add-on finds the Mosquitto broker by itself on Home Assistant OS or Supervised.

FieldRequiredDefaultDescription
broker_urlYes(none)mqtt://host:1883, or mqtts:// for TLS
topicNoscale/body-compositionBase topic; readings go to <topic>/<user slug>, with a single user too
qosNo1QoS level (0, 1 or 2)
retainNotrueRetain the last message
usernameNo(none)Broker username
passwordNo(none)Broker password
client_idNoble-scale-syncMQTT client identifier
ha_discoveryNotrueHome Assistant auto-discovery
ha_device_nameNoBLE ScaleDevice name in Home Assistant
yaml
global_exporters:
  - type: mqtt
    broker_url: 'mqtts://broker.example.com:8883'
    username: myuser
    password: '${MQTT_PASSWORD}'

Message ​

Each weigh-in is one message on <topic>/<user slug>. Masses are in kg whatever scale.weight_unit is:

json
{
  "timestamp": "2026-10-20T21:30:00.000Z",
  "weight": 80,
  "impedance": 500,
  "bmi": 23.9,
  "bodyFatPercent": 18.5,
  "waterPercent": 55.2,
  "boneMass": 3.1,
  "muscleMass": 62.4,
  "visceralFat": 8,
  "physiqueRating": 5,
  "bmr": 1750,
  "metabolicAge": 30
}

timestamp is when the weigh-in was measured, in ISO 8601 UTC: the scale's own time when it sends one, otherwise the time the reading reached the app. Weigh-ins a scale replays from its memory do not go to MQTT, so the message is always the last live weigh-in.

Some scales add skeletalMusclePercent, and the R-MSC04 also ten segment impedances in ohm: impedanceTrunk20kHz, impedanceTrunk100kHz, and the same pairs for LeftArm, RightArm, LeftLeg and RightLeg. A key is in the message only when that weigh-in carries it.

Home Assistant sensors ​

Discovery creates one device per user, with sensors for weight, impedance, BMI, body fat, water, bone mass, muscle mass, visceral fat, physique rating, BMR, metabolic age and the time of the last weigh-in (Last Weigh-in).

The skeletal muscle percentage and the segment impedances get sensors the first time a message carries them: Skeletal Muscle, and one per segment impedance (Impedance Left Arm 20 kHz and so on), which are diagnostic and disabled until you enable them on the device page. A later message without one of these keys shows its sensor as unknown. With retain: false such a sensor can miss the value of the weigh-in that created it, because Home Assistant subscribes to its topic only once the sensor exists.

Availability uses an MQTT last will: if the app drops off the broker without a clean shutdown, the broker publishes offline and the sensors show as unavailable until the app reconnects.

Detect a new weigh-in ​

Trigger on the Last Weigh-in value, not a sensor's last_changed. The message is retained, so every last_changed becomes the time of a Home Assistant restart, and a weigh-in with the same weight as the last one does not change the weight sensor. The timestamp changes with every weigh-in and survives a restart. A state trigger on it needs not_from, because after a restart or a broker reconnect the sensor returns from unknown or unavailable with the old time:

yaml
triggers:
  - trigger: state
    entity_id: sensor.ble_scale_alice_last_weigh_in # your Last Weigh-in sensor
    not_from:
      - unknown
      - unavailable

This trigger skips a weigh-in made while Home Assistant was down. With ha_discovery: false, define the sensor yourself:

yaml
mqtt:
  sensor:
    - name: 'Last Weigh-in'
      state_topic: 'scale/body-composition/alice' # <topic>/<user slug>
      value_template: '{{ value_json.timestamp }}'
      device_class: timestamp

Webhook ​

Sends each weigh-in as JSON to any HTTP endpoint, such as n8n, Make, Zapier or your own API. The body is the MQTT message without timestamp, plus user_name and user_slug.

FieldRequiredDefaultDescription
urlYes(none)Target URL
methodNoPOSTHTTP method
headersNo(none)Custom headers, as a YAML object or a Name: value list separated by commas
timeoutNo10000Request timeout in ms
yaml
global_exporters:
  - type: webhook
    url: 'https://example.com/hook'
    headers:
      X-Api-Key: '${WEBHOOK_API_KEY}'

InfluxDB ​

Writes each weigh-in in line protocol, with floats at 2 decimal places and integers with the i suffix. One exporter covers v2 and v3 through the /api/v2/write endpoint both have.

FieldRequiredDefaultDescription
urlYes(none)InfluxDB server URL
tokenYes(none)API token with write access
orgv2 only(none)Organization name. Leave it out on v3
bucketYes(none)Bucket name on v2, database name on v3
measurementNobody_compositionMeasurement name

InfluxDB v2:

yaml
global_exporters:
  - type: influxdb
    url: 'http://localhost:8086'
    token: '${INFLUXDB_TOKEN}'
    org: my-org
    bucket: my-bucket

InfluxDB v3 (Core, Enterprise, Cloud), where bucket names the database:

yaml
global_exporters:
  - type: influxdb
    url: 'http://localhost:8181'
    token: '${INFLUXDB_TOKEN}'
    bucket: my-database

Ntfy ​

Sends a push notification through ntfy, on ntfy.sh or a self-hosted server.

FieldRequiredDefaultDescription
urlNohttps://ntfy.shNtfy server URL
topicYes(none)Topic name
titleNoScale MeasurementNotification title
priorityNo3Priority (1 to 5)
tokenNo(none)Bearer token auth
usernameNo(none)Basic auth username
passwordNo(none)Basic auth password
report_exportsNofalseAppend the other exporters' results
yaml
global_exporters:
  - type: ntfy
    topic: YOUR_LONG_RANDOM_TOPIC
    priority: 4

The topic is the password

Anyone who knows an ntfy.sh topic can read its recent messages, and each notification carries your name, weight and body composition. Use a long random topic name, a self-hosted server or an access token.

Weight, muscle and bone follow scale.weight_unit. Each notification starts with the user's name as [Name], with a single user too.

With report_exports: true the notification waits for the other exporters and ends with one line per exporter: a check mark and its name, or a cross, its name and the error. Two notifiers with the flag do not report on each other. The notification arrives after the slowest exporter and its retries, about nine minutes with Garmin at the default upload_timeout_sec when every attempt times out. Error text is forwarded cut to 120 characters, so a public topic carries it too.

Telegram ​

Sends a message to a Telegram chat through a bot.

FieldRequiredDefaultDescription
bot_tokenYes(none)Bot token from @BotFather
chat_idYes(none)Target chat ID (numeric) or @channelusername
titleNoScale MeasurementFirst line of the message
silentNofalseDeliver without a notification sound
report_exportsNofalseAppend the other exporters' results
yaml
global_exporters:
  - type: telegram
    bot_token: '${TELEGRAM_BOT_TOKEN}'
    chat_id: '987654321'
  1. Create a bot with @BotFather and copy its token.
  2. Send the bot a message, or add it to the group or channel it should post to.
  3. Open https://api.telegram.org/bot<token>/getUpdates in a browser and copy the chat.id value.

The message is plain text. Weight, muscle and bone follow scale.weight_unit, the user's name comes first as [Name], and report_exports works as for Ntfy.

File (CSV/JSONL) ​

Appends each reading to a local CSV or JSONL file.

FieldRequiredDefaultDescription
file_pathYes(none)Path to the output file
formatNocsvcsv or jsonl
yaml
global_exporters:
  - type: file
    file_path: './measurements.csv'
    format: csv

A CSV file gets a header row on the first write; JSONL gets one JSON object per line. In Docker, a relative file_path is relative to /app. Put the file in a mounted host directory, or it is lost when the container is re-created. Create the directory first with UID 1000 as owner (mkdir data && sudo chown 1000:1000 data), mount it with -v ./data:/app/data and set file_path: './data/measurements.csv'.

Strava ​

Updates the weight in your Strava athlete profile. Requires a Strava API application, which works only while the account that owns it has a Strava subscription; without one, the healthcheck and every update fail with HTTP 403.

FieldRequiredDefaultDescription
client_idYes(none)Strava API application client ID
client_secretYes(none)Strava API application client secret
token_dirNo./strava-tokensDirectory for cached OAuth tokens, relative to the directory of config.yaml
yaml
users:
  - name: Alice
    exporters:
      - type: strava
        client_id: '${STRAVA_CLIENT_ID}'
        client_secret: '${STRAVA_CLIENT_SECRET}'

Create a Strava API application ​

  1. Open strava.com/settings/api.
  2. Upload an Application Icon; the form does not save without one.
  3. Fill in any Application Name, Category and Website.
  4. Set Authorization Callback Domain to localhost.
  5. Save, then copy the Client ID and Client Secret.

Authentication ​

The setup wizard offers Strava authorization after you add the exporter (in edit mode, the Strava Authorization section). For an exporter added by hand, run:

bash
ble-scale-sync setup-strava   # from a clone: npm run setup-strava

In Docker:

bash
mkdir -p strava-tokens
docker run --rm -it \
  -v ./config.yaml:/app/config.yaml \
  -v ./strava-tokens:/app/strava-tokens \
  ghcr.io/kristianp26/ble-scale-sync:latest setup-strava

The script prints a URL to open in a browser. After you authorize, Strava redirects to http://localhost?code=..., a page that does not load; copy the code value from the address bar and paste it into the script. Tokens are refreshed automatically after that.

With several users that each have a Strava exporter, name the one to authorize: ble-scale-sync setup-strava --user alice (name or slug). Each needs its own token_dir.

The container that runs the app needs the same strava-tokens mount, as in the Docker commands, or the exporter does not find the tokens.

Intervals.icu ​

Writes weight and body fat to the Intervals.icu wellness record of the day.

FieldRequiredDefaultDescription
athlete_idYes(none)Intervals.icu athlete ID, for example i123456
api_keyYes(none)API key from Settings > Developer
yaml
users:
  - name: Alice
    exporters:
      - type: intervals
        athlete_id: i123456
        api_key: '${INTERVALS_API_KEY}'

The key is sent with HTTP Basic auth. The day is taken in your Intervals.icu time zone, not the host's, so a container on UTC files a late-evening weigh-in on the right day. A stored weigh-in lands on its own date.

Runalyze ​

Writes weight and body composition to the bodyComposition health metric of Runalyze.

FieldRequiredDefaultDescription
tokenYes(none)Personal API token from Settings > Personal API
yaml
users:
  - name: Alice
    exporters:
      - type: runalyze
        token: '${RUNALYZE_TOKEN}'

Generate the token at runalyze.com/settings/personal-api. Runalyze tokens have an expiry date, so generate a new one when it lapses.

Each reading carries its exact time, so a stored weigh-in lands at its own date and time. Weight, body fat and body water map directly; muscle and bone are sent as a percentage of body weight, as Runalyze stores them. Metrics the scale did not provide are left out.

Wger ​

Writes weight and body composition to Wger, on wger.de or your own instance.

FieldRequiredDefaultDescription
base_urlYes(none)Wger instance URL, for example https://wger.de
tokenYes(none)Permanent API key from <base_url>/en/user/api-key
sync_measurementsNotrueAlso send body fat, water, muscle and bone as custom measurements
yaml
users:
  - name: Alice
    exporters:
      - type: wger
        base_url: https://wger.de
        token: '${WGER_TOKEN}'

The weight is a weight entry on the reading's calendar day, so a stored weigh-in lands on its own date. With sync_measurements, body fat and water (percent) and muscle and bone (kg) become custom measurements; the categories are created on first use. A failed measurement is logged and does not block the weight.

Wger stores the weight in the unit of your Wger profile, so the exporter reads /api/v2/userprofile/ before each export and sends pounds to a profile set to pounds; the API key must be allowed to read the profile. The calendar day comes from the host clock, so in Docker set TZ to your time zone, or a late-evening weigh-in lands on the next day.

HealthLog ​

Writes weight and body composition to HealthLog, a self-hosted personal health record.

FieldRequiredDefaultDescription
base_urlYes(none)HealthLog instance URL, for example https://healthlog.example
tokenYes(none)Measurement ingest token from Settings > API & Tokens
sync_measurementsNotrueAlso send body fat, body water, muscle, bone and visceral fat
yaml
users:
  - name: Alice
    exporters:
      - type: healthlog
        base_url: https://healthlog.example
        token: '${HEALTHLOG_TOKEN}'

Create the token in HealthLog under Settings > API & Tokens > Measurement ingest token. It is shown only once, can only add readings to your own record, and expires after a year: then create a new one and update token.

Each value is a separate measurement with the reading's exact time, so a stored weigh-in lands at its own date and time. Values are sent in the unit HealthLog expects for the type:

HealthLog typeSent asHealthLog accepts
WEIGHTkg1-500
BODY_FATpercent1-80
TOTAL_BODY_WATERkg, weight x body water percent5-100
MUSCLE_MASSkg5-200
BONE_MASSkg0.5-8
VISCERAL_FATrating0-30

Everything after the weight needs sync_measurements. On a scale without impedance these are the BMI-based estimates from body composition. A value outside the accepted range, such as a visceral fat rating above 30, is not sent. A failed body composition value is logged and does not fail the export; a failed weight does. HealthLog answers a repeated value with 409, which the exporter counts as already recorded.

The startup healthcheck calls the public /api/version endpoint, so it confirms the instance is reachable but not that the token is valid; a wrong or expired token shows up on the first export.

Dreeve ​

Writes the weight to Dreeve, a self-hosted sports dashboard. Requires Dreeve 5.3.3 or newer. Dreeve has no place for body composition, so only the weight is sent.

FieldRequiredDefaultDescription
base_urlYes(none)Your Dreeve APP_URL, for example https://dreeve.example
tokenYes(none)API key (drv_...) from Settings > Security > API access
unit_systemYes(none)metric or imperial, the same as Settings > Appearance > Unit system
yaml
users:
  - name: Alice
    exporters:
      - type: dreeve
        base_url: https://dreeve.example
        token: '${DREEVE_TOKEN}'
        unit_system: metric
  1. In the Dreeve admin panel, generate a key under Settings > Security > API access.
  2. Put it in Dreeve's .env as DREEVE_API_KEY.
  3. Recreate the Dreeve containers; a restart does not reread .env.
  4. Use the same key as token.

One Dreeve instance holds one athlete, so Dreeve is a per-user exporter. With more than one user, do not put it under global_exporters: everyone's weigh-ins would land on the same athlete. A base_url with a user name or password in it is refused.

Dreeve keeps one weight per calendar day and the last one sent wins, so a retry never creates a duplicate, but an older reading of the same day sent later (a retry or a replay from the scale's memory) replaces a newer one. The day comes from the host clock, so in Docker set TZ to your time zone. With imperial the weight is sent in pounds; Dreeve does not convert stored entries when its unit system changes, so change unit_system with it.

The startup healthcheck calls /api/v1/status with the key. A 401 means a wrong key, or a Dreeve whose DREEVE_API_KEY is missing or not drv_ followed by 64 hex characters. A 404 on export means a Dreeve older than 5.3.3.

Weigh-ins without body composition ​

With estimate_body_composition: false, a weigh-in the scale measured no body composition for has no body fat, water, bone mass, muscle mass, visceral fat or physique rating. Weight, impedance, BMI, BMR and metabolic age are there as usual. Each exporter leaves the missing values out its own way:

ExporterMissing values
Garmin ConnectNot set; Garmin records the weight, BMI, BMR and metabolic age
MQTTnull in the message; the Home Assistant sensors show unknown until the next measured weigh-in
Webhook, File JSONLnull
File CSVEmpty cells
InfluxDBNo field
Ntfy, TelegramLeft out of the message
Intervals.icu, Runalyze, Wger, HealthLogOnly the weight is sent
Strava, DreeveNo change: they only receive the weight

Secrets ​

Put passwords and tokens in ${ENV_VAR} references, as in the examples above.

See Secret references.

Historical readings ​

Some scales store weigh-ins made while nothing was listening and send them, with the time they were measured, on the next connection. A reading whose recorded time is more than 5 minutes old is a past measurement. It goes only to the exporters that can file it under its own time:

  • Can record a past measurement: Garmin Connect, InfluxDB, File, Intervals.icu, Runalyze, Wger, HealthLog, Dreeve.
  • Skipped: MQTT, Webhook, Ntfy, Telegram, Strava.

A notification is about now, and the MQTT topic holds one retained state, the latest weigh-in, which an older record would overwrite. So a scale that replays its memory fills in your Garmin and InfluxDB history while Home Assistant shows only live weigh-ins. The log says so each time:

text
Historical reading (2026-09-07T06:12:44.000Z): skipping non-back-date exporters [mqtt, ntfy]

The adapters that pass the recorded time on include the Beurer BF720 family, the Renpho ES-26BB, the Arboleaf CS10K and QN scales with a 19-byte scale-info frame, or an 18-byte one with ble.qn_user_key set. On an 18-byte QN scale, a weigh-in marked as stored is a past measurement even if its time is recent. On a Medisana BS430 / BS440 / BS444, every record but the newest one within a minute of now is a past measurement. Salter scales send stored records as ordinary live readings. With runtime.no_reading_rescan_delay set, a weigh-in made during the wait can arrive more than 5 minutes late and is then a past measurement too.

Replay check ​

A scale often hands over a stored copy of the weigh-in the app already exported live. A past measurement within 0.1 kg of the user's last live weigh-in, or of their last_known_weight, is skipped with Skipping replay in the log, unless it was measured more than 5 minutes after that live weigh-in. When the live weigh-in carried the scale's own time, that time is compared, so a scale clock ahead of the host does not make the replay look new.

The weight and time of each user's last live weigh-in are kept per user slug in .last-live-weigh-ins.json next to config.yaml, readable by its owner only, so the check holds across restarts and single-shot runs. Without that file (a .env-only setup, a deleted file, or a saved weight 0.5 kg or more away from last_known_weight at startup) the comparison uses last_known_weight alone, whenever the record was measured. With the single-file config.yaml mount of Docker it is lost when the container is re-created; on the Home Assistant add-on it is in /data. Deleting it while the app is stopped is safe.

Healthchecks ​

At startup each exporter is tested for connectivity. A failure is logged as a warning and does not stop the scan.

ExporterMethod
MQTTConnect + disconnect
WebhookHEAD request
InfluxDB/health endpoint, with token
Ntfy/v1/health endpoint
TelegramgetChat endpoint
Intervals.icuGET wellness record
RunalyzeGET bodyComposition metric
WgerGET userprofile record
HealthLogGET public /api/version
DreeveGET /api/v1/status, with token
GarminNone (Python subprocess)
FileDirectory writable check
StravaNone (avoids API rate limits)

Released under the GPL-3.0 License.
Software made in Europe