Skip to content

Configuration ​

Using the Home Assistant Add-on?

The add-on is configured through the HA UI, not config.yaml. See the Home Assistant Add-on guide for the full option reference.

The fastest way to configure BLE Scale Sync is with the interactive setup wizard. It walks you through scale discovery, user profiles, exporter selection, and connectivity tests:

bash
# Docker (Linux)
mkdir -p garmin-tokens strava-tokens
docker run --rm -it --network host --cap-add NET_ADMIN --cap-add NET_RAW \
  --group-add "$(getent group bluetooth | cut -d: -f3)" -v /var/run/dbus:/var/run/dbus:ro \
  -v ./config.yaml:/app/config.yaml -v ./garmin-tokens:/app/garmin-tokens \
  -v ./strava-tokens:/app/strava-tokens ghcr.io/kristianp26/ble-scale-sync:latest setup

# Standalone (npm install or npx)
npx ble-scale-sync setup

# Standalone (from a clone)
npm run setup

The wizard generates a complete config.yaml. A new setup goes through every section once, then shows the section menu: pick a section to change it (every prompt starts from what you entered), Review & Save, or Quit without saving. A save that you decline (including a No to "Save anyway?" after validation errors) or that cannot write .env returns to the menu with your answers kept, so you can fix a section and save again. Quitting without saving writes nothing and ends setup with exit code 1, so setup && ... stops there. If a config already exists, it offers edit mode, the same menu over that file. Pick any section to reconfigure without starting over: the BLE section starts from the current transport, proxy settings and scale, and a password or token prompt keeps the current value when you press Enter. In the users section each existing user can be kept, edited (every prompt starts from the current value, and keys the wizard does not ask about, such as per-user exporters and last_known_weight, are kept) or removed. Before an edit is saved, Review & Save lists the settings that will change (or says there are no changes), each by its path with the old and new value, such as ~ scale.weight_unit: kg -> lbs or + users[bob].height: 180 (users are named by slug, exporters by position and type, such as global_exporters[1:webhook]). Passwords, tokens, keys, PINs, topics (an ntfy topic works like a password) and every webhook header value are shown as ******** (a changed one as (changed)), a URL only as its scheme and host (https://hooks.example.com/...), and an email with its name hidden; a ${ENV_VAR} reference stays visible. Comments are not shown, and a change to a comment alone is no change. Saving an edit keeps the comments and key order of the file. A comment at the end of a line stays on that line when the wizard changes its value (check one like # cm after switching units), and the comments of a removed user go with that user.

The wizard asks for the weight unit (kg or lbs) and height unit (cm or in) before the user profiles. Changing the height unit later in edit mode converts the heights already entered. A height set through an ${ENV_VAR} reference lives in .env, where the wizard changes no existing line: it prints the converted value to set there instead.

When you type a password or token, the wizard offers to keep it out of config.yaml: the value goes to the .env next to the config (added when you save, existing lines are never changed) and the config gets a reference such as ${GARMIN_PASSWORD} or, with several users, ${GARMIN_PASSWORD_ALICE}. A name that is already set to another value, or that the config already refers to while the wizard cannot see its value (set only by systemd or Compose, say), gets a number (${HA_TOKEN_2}). Only the secrets the saved config still refers to are written, and if .env already has one of those names with a different value, nothing is saved and you are back at the menu. The value is written in single quotes, so that the app and the Garmin login script read the same string; a secret containing a ', ${, two backslashes in a row or a line break cannot be written that way and stays in config.yaml. Typing a reference yourself, like ${HA_TOKEN}, stores it as it is. Inside a Docker container the offer defaults to no, because a .env written there is lost when the container exits unless you mount it.

The token directories are mounted in the Docker command so the Garmin login and the Strava authorization the wizard runs are kept after the container exits. Create them first, as above: a directory Docker creates for a mount is owned by root, and the container does not run as root, so it could not write the tokens.

TIP

For a typical setup you don't need to edit config.yaml manually. The wizard covers BLE scale discovery, units, users, exporters, Garmin and Strava authorization, and exporter connectivity tests. Advanced keys (for example display_unit, unknown_user and the scale-specific ble options) are set by hand.

Validation ​

bash
# Docker
docker run --rm -v ./config.yaml:/app/config.yaml:ro \
  ghcr.io/kristianp26/ble-scale-sync:latest validate

# Standalone (npm install or npx)
npx ble-scale-sync validate

# Standalone (from a clone)
npm run validate

Where config.yaml and .env are read from ​

Outside Docker and the Home Assistant add-on, both files are looked up in the working directory first, and in the package install directory as a fallback. In a git checkout those are the same place, which is why the clone workflow never had to think about it.

Two consequences worth knowing:

  • Under npx ble-scale-sync, the package lives in a cache directory that is deleted again, so the only useful location is the directory you run the command in. Run the command from where your config.yaml lives.
  • config.yaml and .env are always taken from the same directory, never one from each. A stray .env in your working directory is the .env that gets used, so do not keep unrelated ones next to each other.

--config <path> overrides the config file location for the run path, validate, setup, scan and diagnose, and the .env next to that file is the one read. A --config path that does not exist is an error; it no longer falls back to legacy .env mode. A symlinked config.yaml stays a symlink when the app or the wizard writes it. In Docker the file is mounted to /app/config.yaml instead, and on the add-on it lives in /data.

validate checks the same things start does, including that ble.force_scale_adapter names a known adapter and comes with ble.scale_mac.

config.yaml Reference ​

If you prefer manual configuration, here's the full reference. See config.yaml.example for an annotated template.

File version ​

yaml
version: 1
FieldRequiredDefaultDescription
versionYes(none)Config schema version. Must be 1; loading fails without this key

The wizard writes it for you. A hand-written file that omits it fails validation, and the error opens with this key:

Configuration error in config.yaml:

  version
    Invalid input: expected 1

Every problem is reported in one pass, so a file missing several required fields lists them all at once.

BLE ​

yaml
ble:
  scale_mac: 'FF:03:00:13:A1:04'
  # bind_key: '0123456789abcdef0123456789abcdef' # Xiaomi S800 / S400
  # handler: auto
  # noble_driver: abandonware
  # adapter: hci1
  # force_scale_adapter: 'Hutbit'
  # session_timeout_sec: 20
  # qn_protocol_byte: 0
  # qn_report_byte: 252
FieldRequiredDefaultDescription
scale_macRecommendedAuto-discoveryMAC address, or a CoreBluetooth UUID on macOS (bare 32-hex as the wizard writes it, or the dashed form). Prevents connecting to a neighbor's scale.
bind_keyXiaomi S800 / S400(none)32-char hex per-device MiBeacon key from the Mi cloud (extract with the community Xiaomi-cloud-tokens-extractor). Decrypts only the device's own FE95 broadcast. The S400 also needs scale_mac. Keep it secret; it is a credential.
handlerNoautoTransport: auto (local radio), mqtt-proxy (ESP32 over MQTT), esphome-proxy (ESPHome Native API), ha-bluetooth (Home Assistant websocket, broadcast only). See below.
noble_driverNoOS defaultabandonware or stoprocent. Overrides the default BLE driver. Only applies when handler: auto.
adapterNoSystem defaultLinux only. Select a specific Bluetooth adapter (e.g., hci0, hci1). See below.
force_scale_adapterNoAuto-detectName of the scale protocol adapter to use, bypassing auto-detection. Requires scale_mac. See below.
session_timeout_secNo120Seconds of scale silence that end a GATT session (5 to 600); an inbound frame restarts the clock. A session also ends after three times this value even if frames keep arriving, so a chatty scale cannot hold the radio forever, and a whole scan cycle is capped at 15 minutes regardless. Native BLE handlers only; ignored on mqtt-proxy and esphome-proxy. See below.
qn_protocol_byteNoAutoQN-family scales only. Protocol byte the handshake echoes back to the scale (0 to 255). Set it when a QN scale runs the whole handshake and then reports nothing, or when its scale-info frame is lost in transit on a proxy transport. See below.
qn_report_byteNoPer dialectQN-family scales only. Payload byte of the history-response frame (0 to 255). Defaults to 252 (0xFC) on the long-frame dialects (es26m and extended) and 254 (0xFE) on the classic one. Try the other value if your scale completes the handshake and then reports nothing. See below.
auto_clear_stale_bondNofalseDelete a pairing key the scale has forgotten and pair again. Bonded scales only (Beurer BF7xx / BF9xx), node-ble transport only. See below.
preemptive_adapter_resetNotruePower-cycle the Bluetooth adapter with btmgmt after every GATT session, to clear a stuck-discovery state some Raspberry Pi adapters fall into. It was not the cause of the Beurer BF915 re-pairing in #417 (see adapter_privacy). node-ble transport only. See below.
adapter_privacyNofalseTurn on LE privacy on the Bluetooth adapter, with a key derived from its address, so pairing hands the scale a host identity key. For Beurer scales that ask for SET and a new pairing before every weigh-in. Affects the whole adapter. node-ble transport only. See below.
qn_weight_ackNoPer dialectQN-family scales only. Send the scale your weight anchor and acknowledge live weight frames, as the vendor apps do. The acknowledgement is on by default on the 20-byte extended dialect. On the 19-byte dialect (QN: scale info (19B, ...)) true also sends your age and height as the Arboleaf app does. Try true if your QN scale completes the handshake and then streams nothing. See below.
qn_a4_preludeNofalseQN-family scales only. Send the two undecoded 0xA4 frames an Arboleaf vendor app sends between START and the first weight frame. Off by default. Try true only if qn_weight_ack did not help and your scale still goes silent right after START. See below.
qn_time_sync_longNofalseQN-family scales only. Send the 9-byte form of the 0x20 time-sync frame that an Arboleaf vendor app sends, instead of the 8-byte one. Off by default; the extra byte is undecoded. See below.
qn_config_longNofalseQN-family scales only. Send the 10-byte form of the 0x13 config frame the vendor app sends, instead of the 9-byte one. Off by default; the extra bytes are undecoded. See below.
proxy_liveness_timeout_minNo30Minutes of total advertisement silence before a proxy transport is treated as wedged and the process exits for the supervisor to restart. 0 disables. Proxy transports only. See below.
mqtt_proxyIf handler: mqtt-proxy(none)MQTT proxy connection (broker_url, device_id, topic_prefix, username, password, auto_connect, embedded_broker_*). See ESP32 BLE Proxy.
esphome_proxyIf handler: esphome-proxy(none)ESPHome Native API connection (host, port, encryption_key or password, client_info). See ESPHome Bluetooth Proxy.
ha_bluetoothIf handler: ha-bluetooth(none)Home Assistant websocket connection (url, token, optional source scanner filter). Broadcast scales only. See Home Assistant Bluetooth.

Forcing a scale adapter

force_scale_adapter is an escape hatch for when auto-detection routes your scale to the wrong protocol adapter, which happens with rebadged OEM hardware that shares a vendor service with another brand.

Use the adapter name exactly as it appears in the Adapters: line printed at startup:

yaml
ble:
  scale_mac: '03:B3:EC:91:A2:12'
  force_scale_adapter: 'Hutbit'

Two things to know. The forced adapter claims every device it is shown, which is why scale_mac is required: the MAC is what keeps it aimed at your scale. And an unknown name fails at startup with the list of valid ones rather than being ignored.

If you need this, please open an issue with your scale's advertisement, so detection can be fixed for everyone and you can drop the override.

QN scales that connect but never send a weight (qn_protocol_byte)

The QN protocol family (Renpho, Arboleaf, FITINDEX, GE and several rebadges) echoes a protocol byte back to the scale in every configuration command, and the firmware revisions disagree about which value they accept. The wrong value is not an error: the scale acknowledges the entire handshake and then simply never streams a weight, which looks exactly like nobody standing on it.

The scale-info frame length picks the default, and it is right for every unit reported so far. Some firmware wants its own byte rather than 0 or 255: an ES-CS20M that reports 21 needs 21, and the full 0 to 255 range is accepted, so try the value your scale reports before assuming it is a binary choice.

yaml
ble:
  qn_protocol_byte: 0 # or 255; if neither works, the byte your scale reports (an ES-CS20M reporting 21 needs 21)

The debug log states which value is in use. When the scale-info frame arrives:

QN: scale info (19B, dialect=es26m), factor=10, proto=0xff

On a proxy transport that loses the scale-info frame, that line never prints; look for the fallback line instead, which shows the byte the handshake ran with:

QN: fallback: no 0x12 received, running handshake with proto=0x15

If a value makes your scale work, please say so in an issue with the model and that line: the default is set from the models we have evidence for, and yours may change it.

QN scales that still report nothing (qn_report_byte)

If qn_protocol_byte did not help, there is one more byte worth trying, and it is a separate one.

When the scale asks for its configuration (0x21), the handshake answers with a history-response frame:

a0 0d 04 fe 00 00 00 00 00 00 00 00 <checksum>
                ^^

That fe comes from openScale, which took it from a capture of an ES-30M and labels it only as a payload byte.

On the long-frame dialects, es26m (18- or 19-byte) and extended (20-byte), the default is fc, and that one is not an inference. Two vendor-app captures on two different scales agree: one writes a0 0d 04 fc ... five times across three weigh-ins and never sends fe, with the scale echoing the byte back and 59 live weight frames following; the other is an Android capture of a successful weigh-in on a unit whose own log line reads dialect=es26m.

The 11-byte classic dialect keeps fe. No capture covers it, and unlike the long variants it reads today, which is what decides it: every scale reported silent after a completed handshake has been on a long frame.

What the byte actually selects is still not known. Reporters read it as choosing between a live weight stream and the stored-history path, which fits their symptoms, but openScale receives live weight frames while sending fe, so that reading cannot be the whole story. If your scale is on another dialect and goes quiet after the handshake, fc is the value to try:

yaml
ble:
  qn_report_byte: 252 # 0xFC, the value both vendor-app captures send

With debug logging on, every session says which byte it used and why:

QN: history response byte 0xfc (dialect default)
QN: history response byte 0xfe (forced; dialect default 0xfc)

If 252 makes your scale produce a weight, please say so in an issue with the model, the dialect from the QN: scale info line and that log line. Two confirmations on different firmware would be enough to move the default.

QN scales that finish the handshake and then stream nothing (qn_weight_ack)

There is a third silent-failure knob in this family.

One reading of a vendor-app capture of a GE CS 10 G has the app answering live weight frames with an acknowledgement carrying that frame's own weight:

scale  ... 11 1e be ...   ->  app  a2 06 01 1e be 85
scale  ... 11 1e c3 ...   ->  app  a2 06 01 1e c3 8a

That reading is not confirmed on hardware, but the 20-byte extended dialect does it by default. The 20-byte live weight frame is never answered this way: on the 19-byte dialect it is read without any answer (see below), and on the 20-byte extended dialect it is not decoded yet.

There is a second place the same frame appears, and it is the more interesting one for a scale that never streams anything at all. Before the weigh-in the handshake sends a2 06 01 32 <age>, which openScale labels a user profile. Under the reading above those payload bytes are a weight, and 0x32 plus an age decodes to something like 128.58 kg, which is nobody. Two es26m reporters whose scales complete the whole handshake and then go silent have exactly that in their logs.

That default is not changed, because openScale's bytes are what every QN scale in the registry reads with today and two silent units are not enough to move it under the whole family. On the 20-byte extended dialect, turning this on swaps in your configured weight anchor there too. On the 19-byte dialect it drops that frame, since the Arboleaf app sends nothing like it before the start command. On the other dialects that frame stays as it is. Everywhere except the 20-byte dialect the anchor goes after the start command instead (see below).

If your scale completes the whole handshake, is accepted on qn_protocol_byte and qn_report_byte, and then goes quiet, this is the next thing to try:

yaml
ble:
  qn_weight_ack: true

That does two things: your last_known_weight (or the midpoint of your weight_range) is sent to the scale as a weight anchor, and live weight frames are acknowledged with their own weight. false turns the acknowledgement off on every dialect, if it ever turns out to hurt a unit.

Where that anchor goes depends on the dialect. On the 20-byte extended dialect it replaces the placeholder before the weigh-in, and the trigger that dialect always sends after the start command carries it too, which is what hardware confirmed in #235. On every other dialect it is sent twice right after the start command, 75 ms after it and again 150 ms later, and nowhere before it. That is what two HCI captures of an Arboleaf vendor app completing a weigh-in show: the app sends a2 06 01 1c ed b2 (74.05 kg) twice after the start command and nothing like it before.

On the 19-byte dialect (QN: scale info (19B, ...) in the debug log) qn_weight_ack also sends the user profile frame the way the Arboleaf app does, with the first user's age and height, instead of openScale's fixed values. On the one captured unit, the app gets an a1 06 02 01 01 answer to that frame where openScale's version gets a1 06 02 01 00. Make sure the first user's birth_date and height match the profile in the vendor app.

With qn_weight_ack: true and qn_time_sync_long: true (below), one 19-byte Arboleaf has streamed a complete weigh-in; whether it needs both is not known yet, so set both. Reading that stream rests on that single log and is not yet confirmed on hardware (#331). The weight comes from the 20-byte live frame. Once it settles, the scale measures body composition for about 14 seconds more and then sends its results, so the connection stays open until the scale's last result frame: stay on the scale until its display shows the results. If that frame never comes, the weight is sent on its own 40 seconds after it settled, or at once if the scale disconnects first. The scale's own body-composition values and the result block it sends (likely segment impedances) are not decoded, so body composition is estimated from BMI (Deurenberg formula). Only a scale set to display kg has been logged so far; with lb or st the weight frame may differ, and if the log then warns about it, please attach a DEBUG log to the issue.

If that still leaves the scale silent right after START, there is one more thing to try:

yaml
ble:
  qn_a4_prelude: true

An HCI capture of an Arboleaf vendor app shows two 0xA4 frames sent between START and the first live weight frame, which this app does not send. In that capture the scale acknowledges each one and then starts streaming. Turning this on replays those two frames. With qn_weight_ack on, they go out after the two anchor frames, which is the order the other Arboleaf captures show.

Be aware of what that means. The frames are replayed byte for byte from one reporter's capture of their own scale, and their payload is not decoded. Its values have the size and spread of the result block a 19-byte Arboleaf sends after a weigh-in (likely impedance records), so it looks like a previous measurement record of the person who captured it, handed back to the scale. If so, it is right for nobody but that person. That is why it is off by default and why it is the last thing to try rather than the first. If it works for your unit, please say so on issue #331: more than one confirmation is what would turn this from a replay into a decoded frame.

If the scale is still silent, there is one more difference between this app and the vendor app on that capture:

yaml
ble:
  qn_time_sync_long: true

The clock-setting frame is 9 bytes on the app side and 8 bytes here:

vendor app       20 09 ff f3 b3 22 32 08 2a
ble-scale-sync   20 08 ff a1 aa 22 32 c6

Both close under the same checksum rule, and both carry the same little-endian timestamp in the same position, 40 minutes apart on the capture day. The entire difference is one 0x08 before the checksum, and what it selects is not known. Turning this on sends the longer frame.

Every Arboleaf capture so far sends this longer frame, including both that show the anchor going out twice after the start command. So if you have qn_weight_ack on, keep it on and add this rather than swapping one for the other: in those captures the vendor app sends both. Leave qn_a4_prelude off for that run, so that whatever changes can only come from these two.

And if that is also silent, there is one last difference, the only one left between this app's start-up conversation and the vendor app's:

yaml
ble:
  qn_config_long: true

The settings frame sent right after the scale announces itself is 10 bytes on the app side and 9 bytes here. Two independent captures of two different scales agree on the length:

vendor app       13 0a ff 01 10 00 00 02 00 2f
second capture   13 0a ff 01 10 00 00 00 fa 27
ble-scale-sync   13 09 ff 01 10 00 00 00    2c

All three close under the same checksum rule and the first seven bytes are identical, so the whole difference is the pair before the checksum. The two captures disagree on its value, which rules out a constant, so what gets sent here is the vendor app's own pair. What it selects is not known.

Once each of these has been tried and none of them worked, trying them all together is the reasonable next step: the capture shows the vendor app sending all of them in the same session, so it is possible the scale wants the whole sequence rather than any single frame.

With debug on, the anchor is named. On the 20-byte extended dialect:

QN: ready-time A2 carries the configured weight anchor 76.40 kg instead of openScale's placeholder (#75)

On every other dialect:

QN: weight anchor 76.40 kg sent twice after START (ble.qn_weight_ack, sequence from the #331/#75 Android captures)

On the 19-byte dialect, before that:

QN: no A2 before START on the 19-byte dialect, as the vendor app (ble.qn_weight_ack, #331)
QN: A00D profile frame with age 40, height 1750 mm (ble.qn_weight_ack, 19-byte dialect, #331)

And during a 19-byte weigh-in:

QN: 20-byte live frame, weight 76.4 kg at [5..6], status 0x00 (19-byte dialect, #331)
QN: 20-byte stable weight 76.4 kg, holding for the result frames; stay on the scale until its display shows the results (#331)
QN: 0x16 closes the 19-byte weigh-in, publishing 76.4 kg weight-only (impedance block not decoded, #331)

If true makes your scale report a weight, please say so in an issue with the model and the dialect from the QN: scale info log line. So far one 19-byte Arboleaf has streamed its weight with it, and reading that stream is not yet confirmed on hardware; two confirmations would move the default.

QN scales that only work for one person in the house (last_known_weight)

The 20-byte extended dialect (GE CS 10 G, "Fit Plus" and rebadges) is sent a weight anchor immediately after the start command, and the scale gates the weigh-in on it: if the number is far from what the person on the platform actually weighs, the handshake completes normally and then nothing else arrives.

Until 1.26.0 that anchor was a constant replayed from the capture it was decoded in, 77.15 kg, which is why these scales appeared to work for some households and not others. It now comes from your config: users[].last_known_weight when it is set, and the midpoint of users[].weight_range before the first reading lands. Both already exist, so there is nothing new to add.

yaml
users:
  - name: Alex
    weight_range: { min: 70, max: 85 }
    last_known_weight: 76.4 # updated automatically after every reading

The debug log names the value each session runs with:

QN: extended-dialect measurement trigger sent, weight anchor 76.40 kg (#235, #75)

The anchor is taken from the first user in the list, because the scale is handed it before anyone steps on and there is nothing yet to match a person against.

That only covers the moment before the weigh-in. Once the scale starts streaming, every live weight frame is acknowledged with that frame's own weight, exactly as the vendor app does, so the number the scale is told matches whoever is actually standing on it regardless of whose anchor went out first. The anchor is the opening value; the acknowledgements are exact.

A proxy that is connected but no longer delivering (proxy_liveness_timeout_min)

On mqtt-proxy, esphome-proxy and ha-bluetooth the app waits for the proxy to push it a weigh-in. If that link wedges while still looking connected, the wait simply never ends, and from the app's side that is indistinguishable from a house where nobody has stepped on the scale. Both are silence.

What separates them is everything else in range. Advertisements arrive constantly from phones, watches and thermometers while the link is alive, and stop completely when it is not. So a proxy that has delivered nothing at all for half an hour is wedged rather than idle, and the process exits for your supervisor to restart it:

yaml
ble:
  proxy_liveness_timeout_min: 30 # 0 disables the check

The window is deliberately long, and the check counts advertisements from any device, never from your scale alone: your scale only advertises while somebody is standing on it, so it proves nothing about the link.

Raise it or set it to 0 if your proxy sits somewhere genuinely quiet with no other Bluetooth devices in range. A false positive there would restart the add-on every half hour while nothing was actually wrong, which is worse than the problem it solves. The log always says why it fired.

Native handlers have their own watchdog and ignore this setting.

Beurer scales that work once and never again (auto_clear_stale_bond)

A bonded scale can drop its half of the pairing on its own: a battery change does it, and on some units simply ending a session does. The host does not find out. BlueZ keeps replaying the stored key, the peripheral answers "PIN or Key Missing", and every connect from then on fails during encryption before any GATT traffic:

Connect error: le-connection-abort-by-local

Deleting a bond is destructive, so the default is to diagnose it and stop, telling you to run bluetoothctl remove <mac> and pair again. If that is happening to you every session, this does it for you:

yaml
ble:
  auto_clear_stale_bond: true

The bond is cleared at most once per connect, and only after three consecutive authentication-class failures against a device BlueZ still lists as bonded. It stays opt-in because le-connection-abort-by-local also has innocent causes, notably a connect issued while another client (the Home Assistant Bluetooth integration on the same adapter, for instance) still holds a discovery session, and on these scales a bond dropped in error costs a trip to the device to confirm the passkey.

Native BlueZ only. The proxy transports do not pair at all.

The power-cycle after every weigh-in (preemptive_adapter_reset)

After every GATT session the native Linux transport resets its D-Bus connection and then power-cycles the adapter with btmgmt power off / power on. On-board Raspberry Pi Broadcom adapters drift into a state where BlueZ reports discovery as running while the controller has stopped scanning, and the cycle clears it before it builds up. The debug log shows it as Preemptive btmgmt reset after GATT.

It is also the only thing the host does between a bonded session that works and a next connect whose stored key is rejected, which is what the Beurer section above describes. Testing in #417 showed it is not the cause there (see adapter_privacy below), but if you want to rule it out on your setup, switch it off:

yaml
ble:
  preemptive_adapter_reset: false

Only this one step is skipped. The D-Bus reset, the cleanup after a failed session and the recovery that runs when discovery will not start all stay as they are, and the change applies from the next scan cycle without a restart. If your adapter then starts missing the scale after a few weigh-ins, turn it back on.

Native BlueZ (node-ble) only. noble power-cycles the adapter only when it is not powered on at start-up, and the proxy transports never touch the host adapter.

Beurer scales that forget the pairing after every weigh-in (adapter_privacy)

Some Beurer scales (the BF915 is confirmed in #417) keep a pairing only from a device that handed over an identity key (IRK) while pairing. A phone normally does. Linux does so only while the adapter has LE privacy turned on, and it is off by default, so every pairing BlueZ makes is rejected on the next connect with PIN or Key Missing and the scale asks for SET again.

yaml
ble:
  adapter_privacy: true

With this on, at the start of the first scan cycle the app powers the adapter off, turns LE privacy on with btmgmt privacy on <key> and powers it back on, then checks every cycle that privacy is still on. In single-shot mode (a cron job or timer that starts a new process per run) every run is a first cycle, so every run power-cycles the adapter once. The key is derived from the adapter's own Bluetooth address, so it stays the same across restarts, re-created containers, add-on reinstalls and reboots, which is what keeps the scale able to recognise the host. The log names it only by a fingerprint: uses an IRK derived from its address (fingerprint 1a2b3c4d). Right before every connect the app checks once more, and if privacy is not on it skips the connect and says why, rather than pair without the key.

After turning it on, remove the old pairing once (bluetoothctl remove <mac>, or let auto_clear_stale_bond do it) and pair through ble-scale-sync, confirming on the scale as the first time. Later weigh-ins should reuse the pairing, also after the scale has slept.

Before you turn it on:

  • It affects the whole adapter. Every Bluetooth LE connection and active scan on it then uses a random address, including Home Assistant's own Bluetooth integration when it shares the adapter. Other LE devices already paired with that adapter may need pairing again. A second, dedicated USB adapter selected with ble.adapter (ble_adapter in the add-on) keeps everything else out of it.
  • It is not real privacy. Anyone who knows the adapter's address can work out the key and recognise its random addresses. The option exists so the scale keeps the pairing, not to hide the host. The key does not let anyone impersonate the host; that would take the pairing key itself.
  • Do not combine it with Privacy in BlueZ's /etc/bluetooth/main.conf. bluetoothd sets its own key when it starts and this option replaces it, so the scale's stored key depends on which ran last. The app warns when it can read such a line (native installs only; in Docker and the add-on the container's own file is read, not the host's).
  • It needs btmgmt with root or CAP_NET_ADMIN, which the add-on and the Docker image (run with --cap-add NET_ADMIN, as in Getting Started) have. Restart after changing it; it is read once at start-up. Turning it off later does not turn privacy off on the adapter: run sudo btmgmt --index 0 power off, sudo btmgmt --index 0 privacy off and sudo btmgmt --index 0 power on (with your adapter's index), or reboot.

On a native or Docker host there is a no-code alternative: set Privacy = device under [General] in /etc/bluetooth/main.conf and restart bluetoothd (sudo systemctl restart bluetooth). BlueZ then generates an IRK, keeps it in /var/lib/bluetooth/<adapter address>/identity and turns privacy on at every start. Use that or adapter_privacy, not both. Home Assistant OS does not let you edit that file, which is what the option is for.

Native BlueZ (node-ble) only. The proxy transports pair through their own radio, if at all.

Beurer scales that reject every consent code (beurer_register_new_user)

If a Beurer or Sanitas scale bonds, subscribes and then answers the consent with USER_NOT_AUTHORIZED no matter which code or slot you try, the problem is usually not the code.

A user record in the Bluetooth SIG User Data Service exists only after a Register New User operation, and normally only the vendor app performs it. On a scale whose user was registered by the vendor app, another client has no record it is entitled to, and consent can never succeed for it.

Try the scale's own menu first

On a BF915 the scale's menu profiles (SET, U:1 to U:8) are the SIG user slots, and creating one there is the whole job. @martingebert9428 measured it: factory reset, create U:1 in the menu with no BLE operation of any kind, and Register New User then comes back with index 2, because the menu profile has taken slot 1. Consent on index 1 is accepted and returns exactly the date of birth, gender and height entered in the menu.

So on that model the right first move is: create the profile in the menu, read the four-digit number the scale displays when you select it (that is the consent code, no guessing), and set beurer_user_index to the profile's number. Registering from here only burns slots you cannot free individually.

One more step that is invisible from the log: assign one weigh-in to the new profile. A profile with no reference weight makes the scale show U - after weighing and assign the measurement to nobody, and in that state it sends no notification at all, on any characteristic. It looks exactly like a wrong consent code.

Register New User is still the right tool where the vendor app owns the code, which is where it came from.

This creates a record:

yaml
users:
  - name: Your Name
    beurer_pin: 1234 # the code you want the new record to use
    beurer_register_new_user: true

It is opt-in and meant to be used once, because it writes a record to the scale and the slots are finite. The log then tells you which index the scale assigned:

Beurer BF720: registered a new user at index 4. Set 'users[].beurer_user_index: 4'
and turn 'beurer_register_new_user' back off, or the next run registers another one.

Put that index in beurer_user_index, set beurer_register_new_user back to false, and normal consent takes over from the next run.

If the scale refuses the registration, its slots are probably all occupied. Free one from the scale's own menu and try again.

:::

Shortening the session (session_timeout_sec)

Some scales will not run a standalone weigh-in while a host holds the GATT session open. The Beurer BF500 is the clearest example: it displays APP and waits, so only a measurement taken between sessions is picked up.

By default a session ends after 120 seconds without a notification from the scale. On a scale like this, that is 120 seconds out of every cycle in which stepping on it achieves nothing. Shortening the session, and lengthening the gap after it, frees the scale for most of the cycle:

yaml
ble:
  session_timeout_sec: 20
runtime:
  scan_cooldown: 60
  watchdog_max_consecutive_failures: 0

Two costs, both real:

  • More Bluetooth adapter resets. Every read that ends in a timeout triggers one, and shorter sessions mean more timeouts per hour. On a Raspberry Pi that is noticeable.
  • The failure watchdog trips sooner. A session that times out counts as a failed cycle, so shorter sessions reach watchdog_max_consecutive_failures (default 10) in proportionally less time, and the process exits for the supervisor to restart. On a scale where waiting between weigh-ins is normal, raise that limit or set it to 0 to disable it, as above.

This option applies to the native BLE handlers only. On mqtt-proxy, esphome-proxy and ha-bluetooth the watcher waits for a weigh-in indefinitely by design, and the value is ignored.

BLE adapter selection (Linux only)

If your device has multiple Bluetooth adapters, you can choose which one BLE Scale Sync uses. By default, the first adapter (hci0) is used.

List your adapters:

bash
hciconfig
# or
btmgmt info

For example, a Raspberry Pi with a built-in adapter (hci0) and a USB dongle (hci1):

yaml
ble:
  adapter: hci1 # use the USB dongle for scale scanning

This lets you dedicate one adapter to BLE Scale Sync while keeping the other free for other tasks (e.g., Home Assistant Bluetooth proxy). This option is ignored on macOS and Windows, where the OS manages adapter selection.

Scale ​

yaml
scale:
  weight_unit: kg
  height_unit: cm
  display_unit: weight_unit
FieldRequiredDefaultDescription
weight_unitNokgkg or lbs. Display only; calculations always use kg.
height_unitNocmcm or in. Used for height input in user profiles.
display_unitNoweight_unitPhysical scale display unit: weight_unit, kg, lbs, or st. Only scales read by the QN-Scale adapter are told which unit to show (the log says Matched adapter: QN Scale); other scales ignore it. This is independent of exported values and calculations.

For example, this keeps Home Assistant values and matching ranges in kilograms while the physical QN scale shows stones and pounds:

yaml
scale:
  weight_unit: kg
  height_unit: cm
  display_unit: st

Unknown user ​

yaml
unknown_user: nearest # nearest | log | ignore
FieldRequiredDefaultDescription
unknown_userNonearestWhat to do with a reading that matches no user's weight_range
  • nearest attributes it to the user whose configured weight_range has the closest midpoint and exports normally.
  • log records it and exports nothing.
  • ignore drops it silently.

In practice this setting is rarely reached. With one user, that user always matches, so it never applies at all. With several, the matcher first falls back to whoever's last_known_weight is closest to the reading, and that always returns somebody, so nearest and its two alternatives only come into play when no user has a remembered weight yet.

Whether such a reading is exported at all is decided by out_of_range below, not here. Both are hot-reloadable. Full detail, including how matching works: Multi-User Support.

Out-of-range readings ​

yaml
out_of_range: warn # warn | skip
FieldRequiredDefaultDescription
out_of_rangeNowarnWhat to do with a reading no user's weight_range covers. skip stops before export

weight_range is a matching input, not a guard. A reading outside every configured range still resolves to somebody: with one user because that user always matches, and with several because the app falls back to whoever's last_known_weight is closest. It is then exported like any other reading.

That matters when the scale reports something implausible. Standing on it holding a heavy bag can produce a reading tens of kilos out, and because it is exported, last_known_weight is rewritten from it. The next genuine weigh-in is then matched against a wrong remembered weight, so in a two-person household it can be attributed to the other person and lost.

Setting skip stops such a reading before the exporters and before the last_known_weight write. It is logged either way. The default stays warn so no existing setup silently starts discarding measurements after an update, but skip is the better setting for a multi-user household. This is hot-reloadable, like unknown_user.

Users ​

At least one user is required. For multi-user setups, see Multi-User Support.

yaml
users:
  - name: Alice
    slug: alice
    height: 168
    birth_date: '1995-03-20'
    gender: female
    is_athlete: false
    weight_range: { min: 50, max: 75 }
FieldRequiredDefaultDescription
nameYes(none)Display name
slugYes(none)Unique ID (lowercase, hyphens) for MQTT topics, InfluxDB tags. The wizard fills it in from the name, and setup --non-interactive does the same for a file that has one missing; otherwise set it yourself
heightYes(none)Height in configured unit
birth_dateYes(none)ISO date (YYYY-MM-DD)
genderYes(none)male or female
is_athleteYes(none)true or false. Adjusts body composition formulas
weight_rangeYes(none){ min, max } in kg. Also the matching input for multi-user setups
last_known_weightNonullAuto-updated after each measurement. Also used as the weight anchor some scales expect
exportersNo(none)Per-user exporter overrides
beurer_pinBeurer(none)Consent code the Beurer BF7xx / BF9xx scale was paired with
beurer_user_indexNo1Scale user slot the consent code belongs to
beurer_provisionNofalseWrite this profile into a Beurer scale that has no stored user
beurer_register_new_userNofalseCreate a new user record on the scale instead of consenting to one. One-shot; see below

Exporters ​

yaml
global_exporters:
  - type: garmin
    email: '${GARMIN_EMAIL}'
    password: '${GARMIN_PASSWORD}'

Shared by all users unless a user defines their own exporters list. See Exporters for all 12 targets and their configuration fields.

Runtime ​

yaml
runtime:
  continuous_mode: false
  scan_cooldown: 30
  idle_rescan_delay: 5
  retry_failed_exports: true
  dry_run: false
  debug: false
  watchdog_max_consecutive_failures: 10
  watch_config: true
FieldRequiredDefaultDescription
continuous_modeNofalseKeep scanning in a loop (for always-on deployments)
scan_cooldownNo30Seconds to wait after a SUCCESSFUL reading before scanning again (5-3600). It does not govern the wait after a cycle that found no scale - that is idle_rescan_delay. On the native BLE handler in continuous mode, after a successful read the app sleeps at least 25 s regardless of this setting so it does not reconnect while the scale is still advertising (post-disconnect grace, #143).
idle_rescan_delayNo5Seconds to wait before scanning again after a cycle that found no scale while the Bluetooth adapter was healthy (0-3600). Real failures (GATT errors, a wedged controller) keep their own 5 s -> 60 s backoff. Lower it if your scale advertises only for a few seconds after you step on it. Linux/BlueZ (node-ble) only: the other transports cannot tell an idle scan from a failed one, so the setting has no effect there (#398).
retry_failed_exportsNotrueKeep a reading whose export failed and retry it later, for up to 72 hours, 5 attempts and 50 readings. Retries are spaced out, roughly 15 minutes, 1 hour, 6 hours, 24 hours and 71 hours after the failure, and run alongside scanning, so a slow upload never delays the next weigh-in. Only exporters that can record a past measurement are queued (file, garmin, influxdb, intervals, runalyze, wger, healthlog); the others cannot express a past reading, so a failure there is not recoverable and is logged as such. The queue lives next to config.yaml as .export-retry-queue.jsonl, is written 0600 because it holds body composition and a user name, and is deleted when it empties. Set to false to write nothing to disk. Scales that keep their last weigh-ins in memory (Salter) also get a small .scale-dedup-marks.json there (0600), so a restart does not export the same weigh-in again.
dry_runNofalseRead scale + compute body comp, skip exports
debugNofalseVerbose BLE logging
watchdog_max_consecutive_failuresNo10In continuous mode on Linux: exit after this many consecutive scan failures so Docker restart: unless-stopped can recover from a stuck BlueZ controller (0 = disabled). See Troubleshooting.
watch_configNotrueAuto-reload config.yaml on edit (continuous mode only). Set to false to disable and rely on SIGHUP only. See Live Config Reload.

Docker (accepted, unused) ​

yaml
docker:
  mode: pull # pull | build

Accepted by the schema so an older config.yaml still validates, and read by nothing. It described how the setup wizard should obtain the image, which the wizard no longer decides from the config file. Leave it or delete it; neither changes what the app does.

Update Check ​

yaml
update_check: true
FieldRequiredDefaultDescription
update_checkNotrueCheck for newer versions after each measurement (max once per 24h)

After each successful measurement, the app sends a single GET request to api.blescalesync.dev/version. Only the app version, OS, and architecture are sent via the User-Agent header. No personal data is collected. Automatically disabled when CI=true.

The date of the last check is written to .update-check-state.json next to this config file, so the once-per-day limit survives a restart. On the Home Assistant add-on that is /data, on bare Node.js it is the directory holding config.yaml (or your .env when there is no config.yaml). On Docker it is /app inside the container, because -v ./config.yaml:/app/config.yaml mounts the file and not the directory: the cooldown then survives a container restart but not a re-create or an image update. --config cannot be passed through docker run on the published image: the entrypoint's start command runs node dist/index.js with no arguments, and any extra argument replaces the command instead of being forwarded. If you need the cooldown to survive an image update, run on bare Node.js or the Home Assistant add-on, where the state file sits in a directory you control. The file holds a single date and nothing else; if it is missing or unreadable the app simply checks again. Delete it any time.

Anonymous aggregated statistics are visible at stats.blescalesync.dev.

Environment Variables ​

Secret references ​

YAML values support ${ENV_VAR} syntax for passwords and tokens. The variable must be defined in the environment or in a .env file; loading fails if a reference is undefined. Write $${...} for a literal ${...}. A field that expects a number or a true/false value accepts a reference too, when the reference is the whole value (port: ${MQTT_PORT}); booleans take true/false, yes/no, on/off or 1/0. A config reload reads .env again, so a changed secret applies without a restart; variables set in the real environment always win over .env.

yaml
global_exporters:
  - type: garmin
    email: '${GARMIN_EMAIL}'
    password: '${GARMIN_PASSWORD}'

Runtime overrides ​

These environment variables override config.yaml values when they are set in the real environment, useful for Docker -e flags and compose environment:. Each override is logged (without its value). Set only in .env, they do not override; see the note on legacy .env below.

VariableOverrides
CONTINUOUS_MODEruntime.continuous_mode
DRY_RUNruntime.dry_run
DEBUGruntime.debug
SCAN_COOLDOWNruntime.scan_cooldown
BLE_HANDLERble.handler (see the note below)
BLE_WATCHDOG_MAX_FAILURESruntime.watchdog_max_consecutive_failures
SCALE_MACble.scale_mac
NOBLE_DRIVERble.noble_driver
BLE_ADAPTERble.adapter
BLE_RETRY_BASE_DELAY_MSDelay before the first export retry (default 1000)

A value that is not valid for its variable is ignored, the value from config.yaml is kept, and the log says so. An empty value is ignored too, so BLE_WATCHDOG_MAX_FAILURES= no longer turns the watchdog off. Booleans accept true/false, yes/no, on/off and 1/0; a typo such as DRY_RUN=treu no longer reads as false. SCALE_MAC is checked against the same format config.yaml requires.

BLE_HANDLER accepts auto, mqtt-proxy, esphome-proxy and ha-bluetooth. A proxy handler is applied only when that proxy is configured in config.yaml; otherwise the app says so and keeps the configured handler. Any other value is reported and ignored.

Legacy .env support

If config.yaml doesn't exist, the app falls back to .env configuration. See .env.example in the repository. When both files exist, the configuration comes from config.yaml and the exporter and profile variables of the legacy format are not read. .env is still loaded into the environment, though: it supplies the ${VAR} references, but a runtime override left in it (DEBUG, DRY_RUN, SCALE_MAC, ...) is ignored, with a warning that names it. Move the setting into config.yaml or remove the line from .env.

Released under the GPL-3.0 License.