Multi-User Support
When multiple users are configured, the app automatically identifies who stepped on the scale based on the measured weight. The setup wizard walks you through adding users and setting weight ranges, no manual YAML editing needed.
How It Works
- Someone steps on the scale
- The app reads the weight and identifies the user by their weight range
- Body composition is calculated using that user's profile (height, age, gender, athlete mode)
- Data is exported to that user's configured exporters
last_known_weightis updated inconfig.yamlfor better future matching
Each user defines a weight_range so the app knows who's who:
users:
- name: Alice
weight_range: { min: 50, max: 70 }
last_known_weight: null
- name: Bob
weight_range: { min: 75, max: 100 }
last_known_weight: 85.5Only the matching-relevant fields are shown. Every user also needs slug, height, birth_date, gender and is_athlete, and the file needs version: 1 -> see the full reference.
Weight Matching
The app uses a 4-tier priority system to identify users:
| Priority | Condition | Behavior |
|---|---|---|
| 1 | Single user | Always matches (warns if weight is outside range) |
| 2 | Exact range match | One user's range contains the weight |
| 3 | Overlapping ranges | Multiple matches; tiebreak by last_known_weight proximity, then config order |
| 4 | No range match | Closest last_known_weight |
If no match is found, the unknown_user strategy decides what happens:
| Strategy | Behavior |
|---|---|
nearest (default) | Picks the closest range midpoint (with a warning) |
log | Logs a warning and skips |
ignore | Silently skips |
Note what tiers 1 and 4 mean in practice: a reading outside every configured range is not rejected, it is assigned anyway. unknown_user is never consulted, because tier 4 already produced a match. If you want such a reading dropped instead, set out_of_range: skip; see Out-of-range readings. The setup wizard writes out_of_range: skip whenever it saves a config with more than one user.
Some scales also send weigh-ins stored in their memory while the app was not running. Each stored weigh-in is assigned by its own weight (or by the scale's user slot, when the scale sends one), not to whoever is standing on the scale right now. A stored weigh-in that matches nobody, or more than one user equally, is dropped and logged.
Drift Detection
After matching, the app checks if the weight falls in the outer 10% of the user's range. If it does, a warning is logged so you can adjust the range before mismatches start happening.
For example, if Alice's range is 50–70 kg and she weighs 68.5 kg, the app warns that she's near the upper boundary.
Automatic Weight Tracking
After each measurement, the matched user's last_known_weight is automatically updated in config.yaml. This improves matching accuracy over time, especially when ranges overlap. Updates are debounced (5s) and skipped for changes under 0.5 kg.
Per-User Exporters
By default, all users share global_exporters. If a user needs their own export targets (e.g., separate Garmin accounts), define exporters on that user. Their own exporters are added to global_exporters; when a user has their own exporter of a type that is also global (for example garmin), only theirs is used for that type. A list may hold several exporters of one type (two webhooks), and each of them gets the reading:
users:
- name: Alice
# ...
exporters:
- type: garmin
email: '[email protected]'
password: '${ALICE_GARMIN_PASSWORD}'
token_dir: './garmin-tokens/alice'
- name: Bob
# ...
exporters:
- type: garmin
email: '[email protected]'
password: '${BOB_GARMIN_PASSWORD}'
token_dir: './garmin-tokens/bob'
global_exporters:
- type: influxdb
# ... shared by users without their own exporters listOne token directory per account
Garmin and Strava keep each account's login tokens in token_dir. Give every user their own token_dir, as above. If two accounts share one directory, whichever account authenticates last owns the tokens and receives everyone's readings, so the config is rejected at startup with an error naming both users. Two Garmin entries with the same email may share a directory, since they are one account. The setup wizard offers a separate directory per user.
Exporter behavior in multi-user mode
| Exporter | What changes |
|---|---|
| MQTT | Publishes to {topic}/{slug}, per-user HA device + LWT |
| InfluxDB | Adds user={slug} tag to line protocol |
| Webhook | Adds user_name + user_slug fields to JSON |
| Ntfy | Prepends [{name}] to notification (a single user gets it too) |
| Garmin | One account per user, each with its own token_dir |
| Strava | One account per user, each with its own token_dir |
Live Config Reload
In continuous mode, edits to config.yaml are detected automatically and applied before the next scan cycle. No restart, no manual signal. Works on Linux, macOS, and Windows. The config is re-validated before applying; if validation fails, the previous config is kept and an error is logged.
Hot-swappable on edit:
- Exporter list (per-user and
global_exporters) - User profiles (
name,slug,height,birth_date,gender,is_athlete,weight_range,last_known_weight) scale.weight_unit,scale.height_unitunknown_userstrategyout_of_rangestrategyruntime.dry_run,runtime.debug,runtime.scan_cooldown,runtime.idle_rescan_delayble.scale_macupdate_check
Restart-required (the change is detected and logged with a warning, but only takes effect after restart): runtime.retry_failed_exports (read once at startup), ble.handler, ble.adapter, ble.adapter_privacy, ble.noble_driver, ble.force_scale_adapter, every ble.mqtt_proxy.* field including embedded_broker_port and embedded_broker_bind, every ble.esphome_proxy.* field including client_info, additional_proxies and advertisement_timeout, ble.ha_bluetooth.url, ble.ha_bluetooth.token, ble.ha_bluetooth.source, runtime.continuous_mode, runtime.watchdog_max_consecutive_failures. Adding or removing users, including switching between one user and several, applies on reload.
Everything not in that list is hot-swapped, including the keys people most often tune while a scale is misbehaving: ble.session_timeout_sec, ble.auto_clear_stale_bond, ble.preemptive_adapter_reset, ble.bind_key, every ble.qn_* option and ble.proxy_liveness_timeout_min. On the proxy transports the liveness timeout is re-read when the next advertisement wait begins, so a change to it lands on the next cycle rather than the same instant.
In Docker with config.yaml mounted as a single file, edits made in place are picked up. An editor that saves by writing a new file and renaming it over the old one replaces the file on the host, and the container keeps seeing the old one; after such an edit send SIGHUP or restart the container, or mount the directory instead of the file.
To opt out (e.g. on a flaky network filesystem) and rely solely on the SIGHUP flow:
runtime:
watch_config: falseSIGHUP still works as a manual fallback on Linux/macOS:
kill -HUP $(pgrep -f "ble-scale-sync")