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.
Setup Wizard (recommended)
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:
# Docker (Linux)
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 ghcr.io/kristianp26/ble-scale-sync:latest setup
# Standalone (npm install or npx)
npx ble-scale-sync setup
# Standalone (from a clone)
npm run setupThe wizard generates a complete config.yaml. If a config already exists, it offers edit mode: pick any section to reconfigure without starting over.
TIP
You don't need to edit config.yaml manually. The wizard handles everything, including BLE scale auto-discovery, Garmin authentication, and exporter connectivity tests.
Validation
# 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 validateWhere 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 yourconfig.yamllives. config.yamland.envare always taken from the same directory, never one from each. A stray.envin your working directory is the.envthat gets used, so do not keep unrelated ones next to each other.
--config <path> overrides the config file location for the run path, for validate and for setup. In Docker the file is mounted to /app/config.yaml instead, and on the add-on it lives in /data.
config.yaml Reference
If you prefer manual configuration, here's the full reference. See config.yaml.example for an annotated template.
File version
version: 1| Field | Required | Default | Description |
|---|---|---|---|
version | Yes | (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 1Every problem is reported in one pass, so a file missing several required fields lists them all at once.
BLE
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| Field | Required | Default | Description |
|---|---|---|---|
scale_mac | Recommended | Auto-discovery | MAC 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_key | Xiaomi 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. |
handler | No | auto | Transport: auto (local radio), mqtt-proxy (ESP32 over MQTT), esphome-proxy (ESPHome Native API), ha-bluetooth (Home Assistant websocket, broadcast only). See below. |
noble_driver | No | OS default | abandonware or stoprocent. Overrides the default BLE driver. Only applies when handler: auto. |
adapter | No | System default | Linux only. Select a specific Bluetooth adapter (e.g., hci0, hci1). See below. |
force_scale_adapter | No | Auto-detect | Name of the scale protocol adapter to use, bypassing auto-detection. Requires scale_mac. See below. |
session_timeout_sec | No | 120 | Seconds 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_byte | No | Auto | QN-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_byte | No | Per dialect | QN-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_bond | No | false | Delete a pairing key the scale has forgotten and pair again. Bonded scales only (Beurer BF7xx / BF9xx), node-ble transport only. See below. |
qn_weight_ack | No | Per dialect | QN-family scales only. Answer every live weight frame with its own weight, as the vendor app does. On by default on the 20-byte extended dialect. Try true if your QN scale completes the handshake and then streams nothing. See below. |
qn_a4_prelude | No | false | QN-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_long | No | false | QN-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_long | No | false | QN-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_min | No | 30 | Minutes 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_proxy | If handler: mqtt-proxy | (none) | MQTT proxy connection (broker_url, device_id, topic_prefix, username, password, auto_connect, embedded_broker_*). See ESP32 BLE Proxy. |
esphome_proxy | If handler: esphome-proxy | (none) | ESPHome Native API connection (host, port, encryption_key or password, client_info). See ESPHome Bluetooth Proxy. |
ha_bluetooth | If 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:
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.
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=0xffOn 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=0x15If 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 (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:
ble:
qn_report_byte: 252 # 0xFC, the value both vendor-app captures sendWith 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, and it is the one with the clearest evidence behind it.
A vendor-app capture of a GE CS 10 G answers every live weight frame the scale sends 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 8aOn that firmware the scale will not finish a weigh-in without it, so the 20-byte extended dialect does this by default and needs no setting.
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. Turning this on swaps in your configured weight anchor there too, so the scale is told a plausible number before it decides whether to measure.
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:
ble:
qn_weight_ack: trueThat does two things: an A2 frame carries your last_known_weight (or the midpoint of your weight_range) instead of the placeholder, and every live weight frame is acknowledged with its own weight. false turns both off everywhere, if it ever turns out to hurt a unit.
Where that anchor goes depends on the dialect, and it goes to exactly one place either way. On the 20-byte extended dialect it is sent after the start command, because that is where hardware confirmed it in #235. On every other dialect it is sent immediately before the start command, which is where an HCI capture of an Arboleaf vendor app puts it: that app sends a2 06 01 22 8d 58 (88.45 kg) and then the start command with nothing between them.
If that still leaves the scale silent right after START, there is one more thing to try:
ble:
qn_a4_prelude: trueAn 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. The scale acknowledges each one and only then starts streaming. Turning this on replays those two frames.
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. It looks like per-user calibration or a previous measurement handed back, so it may be right for everyone or right for nobody but the person who captured it. 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 with that on, there is one more difference between this app and the vendor app on that capture, and it is the last one anybody has found:
ble:
qn_time_sync_long: trueThe 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 c6Both 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.
Try it on its own, not together with qn_a4_prelude or qn_weight_ack. Changing two things at once makes the result unreadable, which is the whole reason these are separate switches.
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:
ble:
qn_config_long: trueThe 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 2cAll 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 option has been tried on its own and none of them worked, trying them 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 swap is named:
QN: ready-time A2 carries the configured weight anchor 76.40 kg instead of openScale's placeholder (#75)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. 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.
users:
- name: Alex
weight_range: { min: 70, max: 85 }
last_known_weight: 76.4 # updated automatically after every readingThe 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:
ble:
proxy_liveness_timeout_min: 30 # 0 disables the checkThe 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-localDeleting 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:
ble:
auto_clear_stale_bond: trueThe 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.
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:
users:
- name: Your Name
beurer_pin: 1234 # the code you want the new record to use
beurer_register_new_user: trueIt 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:
ble:
session_timeout_sec: 20
runtime:
scan_cooldown: 60
watchdog_max_consecutive_failures: 0Two 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 to0to 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:
hciconfig
# or
btmgmt infoFor example, a Raspberry Pi with a built-in adapter (hci0) and a USB dongle (hci1):
ble:
adapter: hci1 # use the USB dongle for scale scanningThis 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
scale:
weight_unit: kg
height_unit: cm| Field | Required | Default | Description |
|---|---|---|---|
weight_unit | No | kg | kg or lbs. Display only; calculations always use kg. |
height_unit | No | cm | cm or in. Used for height input in user profiles. |
Unknown user
unknown_user: nearest # nearest | log | ignore| Field | Required | Default | Description |
|---|---|---|---|
unknown_user | No | nearest | What to do with a reading that matches no user's weight_range |
nearestattributes it to the user whose configuredweight_rangehas the closest midpoint and exports normally.logrecords it and exports nothing.ignoredrops 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
out_of_range: warn # warn | skip| Field | Required | Default | Description |
|---|---|---|---|
out_of_range | No | warn | What 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.
users:
- name: Alice
slug: alice
height: 168
birth_date: '1995-03-20'
gender: female
is_athlete: false
weight_range: { min: 50, max: 75 }| Field | Required | Default | Description |
|---|---|---|---|
name | Yes | (none) | Display name |
slug | Yes | (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 |
height | Yes | (none) | Height in configured unit |
birth_date | Yes | (none) | ISO date (YYYY-MM-DD) |
gender | Yes | (none) | male or female |
is_athlete | Yes | (none) | true or false. Adjusts body composition formulas |
weight_range | Yes | (none) | { min, max } in kg. Also the matching input for multi-user setups |
last_known_weight | No | null | Auto-updated after each measurement. Also used as the weight anchor some scales expect |
exporters | No | (none) | Per-user exporter overrides |
beurer_pin | Beurer | (none) | Consent code the Beurer BF7xx / BF9xx scale was paired with |
beurer_user_index | No | 1 | Scale user slot the consent code belongs to |
beurer_provision | No | false | Write this profile into a Beurer scale that has no stored user |
beurer_register_new_user | No | false | Create a new user record on the scale instead of consenting to one. One-shot; see below |
Exporters
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 11 targets and their configuration fields.
Runtime
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| Field | Required | Default | Description |
|---|---|---|---|
continuous_mode | No | false | Keep scanning in a loop (for always-on deployments) |
scan_cooldown | No | 30 | Seconds 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_delay | No | 5 | Seconds 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_exports | No | true | Keep a reading whose export failed and retry it on a later cycle, for up to 72 hours, 5 attempts and 50 readings. Only exporters that can record a past measurement are queued (file, garmin, influxdb, intervals, runalyze, wger); 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. |
dry_run | No | false | Read scale + compute body comp, skip exports |
debug | No | false | Verbose BLE logging |
watchdog_max_consecutive_failures | No | 10 | In 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_config | No | true | Auto-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)
docker:
mode: pull # pull | buildAccepted 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
update_check: true| Field | Required | Default | Description |
|---|---|---|---|
update_check | No | true | Check 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.
global_exporters:
- type: garmin
email: '${GARMIN_EMAIL}'
password: '${GARMIN_PASSWORD}'Runtime overrides
These environment variables always override config.yaml values, useful for Docker -e flags:
| Variable | Overrides |
|---|---|
CONTINUOUS_MODE | runtime.continuous_mode |
DRY_RUN | runtime.dry_run |
DEBUG | runtime.debug |
SCAN_COOLDOWN | runtime.scan_cooldown |
BLE_HANDLER | ble.handler (see the note below) |
BLE_WATCHDOG_MAX_FAILURES | runtime.watchdog_max_consecutive_failures |
SCALE_MAC | ble.scale_mac |
NOBLE_DRIVER | ble.noble_driver |
BLE_ADAPTER | ble.adapter |
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, config.yaml takes priority.