Skip to content

Configuration ​

Every key of config.yaml with its default. The Home Assistant add-on has its own options, see the Home Assistant Add-on guide.

The wizard writes a complete config.yaml: scale discovery, units, users, exporters, Garmin and Strava sign-in and exporter connectivity tests. Run it with ble-scale-sync setup, with npm run setup from a clone, or with the Docker command in Getting started.

With an existing config.yaml the wizard offers edit mode: a menu of sections, each prompt starting from the current value. Review & Save lists every setting that changes with its old and new value, with passwords, tokens and topics masked. Saving keeps the comments and key order of the file. Quit without saving writes nothing and exits with code 1.

When you type a password or token, the wizard offers to move it to the .env next to config.yaml and write a reference such as ${GARMIN_PASSWORD} instead (see Secret references). Existing .env lines are never changed. Inside a Docker container the offer defaults to no, because a .env written there is lost when the container exits unless you mount it.

Set by hand: the keys the wizard does not ask about, such as display_unit, unknown_user and the scale-specific ble keys.

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

validate runs the same checks as start, including that ble.force_scale_adapter names a known adapter and comes with ble.scale_mac. Every problem is reported in one pass.

Config file location ​

  • Docker: /app/config.yaml, mounted from the host. The image's default command takes no --config, so mount the file at that path. Home Assistant add-on: /data.
  • Everywhere else: the working directory first, then the package install directory. Under npx the package lives in a temporary cache, so run the command from the directory that holds your config.yaml.
  • config.yaml and .env always come from the same directory.
  • --config <path> sets the file for the run path, validate, setup, scan and diagnose, and the .env next to it is read. A path that does not exist is an error.

config.yaml Reference ​

config.yaml.example is an annotated template.

File version ​

yaml
version: 1
KeyDefaultDescription
version(none)Required. Config schema version, must be 1. The wizard writes it.
docker.modepullAccepted and ignored.

BLE ​

yaml
ble:
  scale_mac: 'FF:03:00:13:A1:04'
KeyDefaultDescription
scale_mac(none)MAC address of your scale, or its CoreBluetooth UUID on macOS. Without it the app connects to the first supported scale it finds, which can be a neighbor's.
bind_key(none)Xiaomi S800 and S400 only. 32-character hex MiBeacon key from the Mi cloud, needed to decrypt the broadcast. Keep it secret. See Supported scales.
handlerautoTransport: auto (this host's Bluetooth), mqtt-proxy, esphome-proxy or ha-bluetooth. See Choosing a transport.
noble_driverOS defaultabandonware or stoprocent. Only with handler: auto. See noble drivers.
adapterSystem defaultLinux only. Bluetooth adapter to use, such as hci1. btmgmt info lists them.
force_scale_adapterAuto-detectName of a scale protocol adapter, exactly as in the Adapters: line at startup, to bypass detection. Requires scale_mac, because a forced adapter claims every device. An unknown name fails at startup. Report the misdetection in an issue.
session_timeout_sec120Seconds of scale silence that end a GATT session, 5 to 600. A session also ends after three times this value. Host adapter, and mqtt-proxy outside continuous mode. See when a session ends.
auto_clear_stale_bondfalseDelete a pairing key the scale has forgotten and pair again, only on evidence that the scale refused the key. Linux built-in Bluetooth only. See Beurer and Sanitas.
pairing_retry_max_min60Longest wait in minutes, 0 to 1440, before asking again for a pairing nobody accepted on the scale. 0 asks on every scan. Linux built-in Bluetooth, continuous mode only; a single run asks once. See Beurer and Sanitas.
preemptive_adapter_resettruePower-cycle the adapter with btmgmt after every GATT session, against a stuck-discovery state on some Raspberry Pi adapters. Linux built-in Bluetooth only. See Troubleshooting.
adapter_privacyfalseTurn on LE privacy on the whole adapter, so pairing hands the scale a host identity key. For Beurer scales that ask for a new pairing before every weigh-in. Linux built-in Bluetooth only. See Beurer and Sanitas.
proxy_liveness_timeout_min30Minutes without any advertisement, from any device, after which a proxy transport is treated as stuck and the process exits for its supervisor to restart it. 0 disables. Proxy transports only.
mqtt_proxy(none)Required with handler: mqtt-proxy. See ESP32 BLE proxy.
esphome_proxy(none)Required with handler: esphome-proxy. See ESPHome Bluetooth proxy.
ha_bluetooth(none)Required with handler: ha-bluetooth. See Home Assistant Bluetooth.

Scale-family keys ​

These keys change what the app sends to one family of scales. Leave them unset unless the scale page tells you to try one.

KeyDefaultDescription
qn_protocol_byte(none)QN scales. Protocol byte the handshake echoes, 0 to 255. See QN scales.
qn_report_byte(none)QN scales. Deletes the user slots this mask selects on the scale, 0 to 255, on every connection. See QN scales.
qn_weight_ackPer dialectQN scales. Send the weight anchor and acknowledge live weight frames. See QN scales.
qn_user_key(none)QN scales with a scale-info frame of 18 bytes or longer. User key of the profile frame, 1 to 9999. See QN scales.
qn_user_slot(none)QN scales with a scale-info frame of 18 bytes or longer. User slot of the profile frame, 1 to 8, only with qn_user_key. Experimental. See QN scales.
qn_algorithm(none)QN scales. Algorithm byte of the profile frame, 0 to 255, sent where the vendor-app profile frame goes out. Experimental. See QN scales.
qn_time_sync_longfalseQN scales. Send the 9-byte time-sync frame with the host's UTC offset. See QN scales.
qn_config_longfalseQN scales. Send the 10-byte config frame. See QN scales.
qn_a4_preludefalseQN scales. Send two extra 0xA4 frames the vendor app sends. They overwrite data the scale keeps for its first user. See QN scales.
qn_result_ackfalseQN scales with a 19-byte scale-info frame and the Arboleaf CS10K. Send the end-of-measurement frame. Experimental. See QN scales.
rmsc04_profile_byte(none)Renpho R-MSC04. One profile byte as two hex digits in quotes, to export the scale's own body fat. Personal. See Renpho.

Scale ​

yaml
scale:
  weight_unit: kg
KeyDefaultDescription
weight_unitkgkg or lbs. Unit of weights in the log and in notification texts. Calculations, weight_range and exported values stay in kg.
height_unitcmcm or in. Unit of users[].height.
display_unitweight_unitUnit to show on the scale's own display: weight_unit, kg, lbs or st. Only QN scales and the Active Era BS-06 adapter are told; other scales ignore it.

Unknown user ​

yaml
unknown_user: nearest
KeyDefaultDescription
unknown_usernearestWhat happens to a reading no user matches, with several users and no last_known_weight yet: nearest (the user whose weight_range midpoint is closest), log (logged, not exported) or ignore. See Multi-user.

Out-of-range readings ​

yaml
out_of_range: skip
KeyDefaultDescription
out_of_rangewarnWhat happens to a reading outside the weight_range of the user it goes to. warn logs and exports it; skip logs it and stops before the exporters and the last_known_weight update. skip suits a household with several users. See Multi-user.

Users ​

At least one user is required.

yaml
users:
  - name: Alice
    slug: alice
    height: 168
    birth_date: '1995-03-20'
    gender: female
    is_athlete: false
    weight_range: { min: 50, max: 75 }
KeyDefaultDescription
name(none)Required. Display name.
slug(none)Required. Unique ID of lowercase letters, digits and hyphens, used in MQTT topics, InfluxDB tags and file names. The wizard derives it from the name.
height(none)Required. Height in scale.height_unit.
birth_date(none)Required. YYYY-MM-DD, a real date not in the future.
gender(none)Required. male or female.
is_athlete(none)Required. true or false. See athlete mode.
weight_range(none)Required. { min, max } in kg. Matches readings to users and gives the weight anchor some scales need. See Multi-user.
last_known_weight(none)Updated after each reading. Used for matching, as the weight anchor and by the replay check.
exporters(none)Exporters for this user only. See per-user exporters.
estimate_body_compositiontruefalse: a weigh-in without measured body composition exports no body fat, water, bone mass, muscle mass, visceral fat or physique rating instead of an estimate. See Exporters.
beurer_pin(none)Beurer and Sanitas scales with a consent code, 0 to 9999. Read from the first user. See Beurer and Sanitas.
beurer_user_index1User slot on the scale that the consent code belongs to, read from the first user like beurer_pin. With several users, a set value also assigns that slot's weigh-ins to this user. See Scale user slot.
beurer_provisionfalseWrite this profile into a Beurer scale that has no stored user.
beurer_register_new_userfalseCreate a new user record on the scale, once. See Beurer and Sanitas.

The weight and time of each user's last live weigh-in are kept in .last-live-weigh-ins.json next to config.yaml, for the replay check.

Exporters ​

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

global_exporters apply to every user. An exporter in a user's own exporters list is added for that user, and one of a type that is also global replaces the global one for that user. Every exporter and its fields are on Exporters.

Runtime ​

yaml
runtime:
  continuous_mode: true
KeyDefaultDescription
continuous_modefalseKeep scanning in a loop instead of exiting after one reading.
scan_cooldown30Seconds to wait after a successful reading, 5 to 3600. Host adapter only; at least 25 s on Linux built-in Bluetooth.
idle_rescan_delay5Seconds to wait after a cycle that found no scale while the adapter was healthy, 0 to 3600. Linux built-in Bluetooth only.
no_reading_rescan_delay0Minimum seconds to wait, 0 to 3600, after a session in which the scale answered and then sent nothing until it timed out. Nothing is scanned or retried during the wait. Linux built-in Bluetooth, continuous mode only. See Beurer and Sanitas.
retry_failed_exportstrueKeep a reading whose export failed and retry it about 15 minutes, 1, 6, 24 and 71 hours later, up to 50 readings. Only exporters that can record a past measurement are retried. See Exporters.
dry_runfalseRead the scale and compute body composition, export nothing.
debugfalseVerbose logging, including BLE frames.
watchdog_max_consecutive_failures10In continuous mode on the host adapter, exit after this many failed scan cycles in a row so the supervisor restarts the app. With a noble driver a scan that did not find the scale counts too. 0 disables. See Troubleshooting.
watch_configtrueReload config.yaml when it changes, in continuous mode. false leaves SIGHUP as the only trigger. See Live Config Reload.

In continuous mode the next scan waits scan_cooldown after a reading and idle_rescan_delay after a cycle that found no scale. After a failure, or a session that timed out, it waits 5 s, doubling up to 60 s while failures continue, or no_reading_rescan_delay when that is longer. The log line names the case, such as No scale found, rescanning in 5s... (Device not found). The proxy transports listen all the time and use none of these waits.

The retry queue is .export-retry-queue.jsonl next to config.yaml, mode 0600, deleted when empty. In Docker it is lost when the container is re-created, see Run.

Update check ​

yaml
update_check: false
KeyDefaultDescription
update_checktrueAfter a measurement, at most once per UTC day, ask api.blescalesync.dev/version for the latest version. Off when CI=true.

The request sends only the User-Agent ble-scale-sync/<version> (<platform>; <arch>). The date of the last check is kept in .update-check-state.json next to config.yaml. The anonymous totals are public at stats.blescalesync.dev.

Live config reload ​

In continuous mode the app reloads config.yaml when the file changes and applies it before the next scan cycle, on Linux, macOS and Windows. The proxy transports have no scan cycle. They apply a change after the next weigh-in, so restart the app when the change must hold for the next one, such as dry_run. An invalid file is not applied: the previous config stays and the log says why. A change to a key that needs a restart is logged with Restart required for this field to take effect.

KeyAppliesNote
users (every field, adding and removing users)Reload
global_exporters, users[].exportersReload
scale.*, unknown_user, out_of_range, update_checkReload
runtime.dry_run, debug, scan_cooldown, idle_rescan_delayReload
runtime.no_reading_rescan_delayReloadFrom the next session without a reading; a wait already running is not shortened.
ble.scale_mac, bind_key, session_timeout_secReload
ble.auto_clear_stale_bond, pairing_retry_max_min, preemptive_adapter_resetReload
ble.qn_*, ble.rmsc04_profile_byteReload
ble.proxy_liveness_timeout_minReloadFrom the next advertisement wait.
ble.handler, adapter, noble_driver, adapter_privacy, force_scale_adapterRestart
ble.mqtt_proxy.*, ble.esphome_proxy.*, ble.ha_bluetooth.*RestartEvery field.
runtime.continuous_mode, retry_failed_exports, watchdog_max_consecutive_failures, watch_configRestart

A reload also reads .env again, so a changed secret applies without a restart.

In Docker with config.yaml mounted as a single file, an edit made in place is picked up. A tool that saves by writing a new file and renaming it, such as sed -i and some editors, replaces the file on the host while the container keeps the old one; only a container restart (docker restart ble-scale-sync) picks that up.

To reload by hand on Linux or macOS, also with watch_config: false, send SIGHUP to the app:

bash
sudo systemctl kill -s HUP ble-scale   # systemd service
docker kill -s HUP ble-scale-sync      # Docker
kill -HUP NODE_PID                     # any other node process

Environment variables ​

Secret references ​

Any value can be a ${ENV_VAR} reference, taken from the environment or from the .env next to config.yaml. Loading fails if a referenced variable is not set. A reference that is the whole value also works for numbers and booleans (port: ${MQTT_PORT}). Write $${...} for a literal ${...}. Variables set in the real environment win over .env.

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

Runtime overrides ​

These variables override config.yaml when they are set in the real environment, such as Docker -e or Compose environment:. When a config.yaml exists, an override set only in .env is ignored with a warning. Each override is logged without its value.

VariableOverrides
CONTINUOUS_MODEruntime.continuous_mode
DRY_RUNruntime.dry_run
DEBUGruntime.debug
SCAN_COOLDOWNruntime.scan_cooldown
BLE_HANDLERble.handler
BLE_WATCHDOG_MAX_FAILURESruntime.watchdog_max_consecutive_failures
SCALE_MACble.scale_mac
NOBLE_DRIVERble.noble_driver
BLE_ADAPTERble.adapter

An empty or invalid value is ignored, the value from config.yaml stays and the log says so. Booleans accept true/false, yes/no, on/off and 1/0. BLE_HANDLER takes a proxy handler only when that proxy is configured in config.yaml.

BLE_RETRY_BASE_DELAY_MS sets the base wait in ms between the attempts of one export call (default 1000). It does not change the retry queue.

Legacy .env support

Without a config.yaml the app reads its whole configuration from .env, see .env.example in the repository. When both exist, the configuration comes from config.yaml and .env only supplies the ${VAR} references.

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