Skip to content

Troubleshooting

For general questions that are not about a specific error (supported scales, Garmin 2FA, privacy, multi-user, deployment choices), start with the FAQ.

BLE / Scale Issues

Scale not found

  • Step on the scale to wake it up. Most scales go to sleep after a few seconds of inactivity.
  • Verify with ble-scale-sync scan (npm run scan from a clone, or the Docker scan command) that your scale is visible.
  • If using scale_mac, double-check the address matches the scan output.
  • On Linux, make sure Bluetooth is running: sudo systemctl status bluetooth

Connection fails on Raspberry Pi

The app automatically stops BLE discovery before connecting, which resolves most le-connection-abort-by-local errors. If connections still fail:

bash
sudo systemctl restart bluetooth

Then step on the scale and try again.

Scale was found before but now isn't discovered (Linux)

BlueZ can sometimes stop reporting previously-seen devices. Restart Bluetooth and try again:

bash
sudo systemctl restart bluetooth

Permission denied (Linux)

Grant BLE capabilities to Node.js:

bash
sudo setcap cap_net_raw+eip $(eval readlink -f $(which node))

You need to re-run this after every Node.js update.

Windows BLE issues

  • The default driver (@abandonware/noble) works with the native Windows Bluetooth stack, no special setup needed.
  • If using NOBLE_DRIVER=stoprocent, install the WinUSB driver via Zadig.
  • Run your terminal as Administrator if you get permission errors.

Switching the BLE transport (ble.handler)

BLE Scale Sync supports three transport handlers. The default auto picks the right local-radio stack per OS. Switch to mqtt-proxy or esphome-proxy only if you're using a remote BLE radio.

yaml
ble:
  handler: auto # or: mqtt-proxy, esphome-proxy
HandlerUse when
autoDefault. Local Bluetooth radio on the host (Linux/macOS/Windows).
mqtt-proxyRemote ESP32 with custom firmware over MQTT. See ESP32 BLE Proxy.
esphome-proxyExisting ESPHome BT proxy over Native API. See ESPHome Proxy.

Switching the noble driver (ble.noble_driver)

For local-radio mode (handler: auto), three OS-specific BLE stacks are available. If connections fail repeatedly, try the alternative driver:

yaml
ble:
  noble_driver: stoprocent # or: abandonware
DriverPlatformsNotes
node-ble (default on Linux)Linux onlyUses BlueZ D-Bus. Most reliable on Raspberry Pi. Service UUIDs not available during scan (only after connecting).
@abandonware/noble (default on Windows)Linux, WindowsMature driver. Uses WinRT on Windows. Builds from source, so it needs a C++ toolchain.
@stoprocent/noble (default on macOS)Linux, macOS, WindowsNewer driver, ships prebuilt binaries. Exposes service UUIDs during scan. On Windows, requires the WinUSB driver.

Note

On Linux, node-ble is always used regardless of noble_driver. The flag only applies on macOS/Windows or when explicitly set.

The three stacks are optional dependencies, so a host that could not build one of them simply does not have it. If the transport you select is not installed, the app names the npm package, the install command and the transports that are still available. Install one directly with npm install @stoprocent/noble (or @abandonware/noble, or node-ble).

If your scale is not being recognized during scan but you know its MAC address, set scale_mac in config.yaml. The adapter will match post-connect using GATT service UUIDs regardless of the driver.

Log line:

Beurer BF720: consent rejected (USER_NOT_AUTHORIZED)
Beurer BF720: the scale reports no stored user profiles

Removing the batteries can wipe every user slot and its consent code. No code the scale was previously paired with is then correct, because the slot it belonged to no longer exists. The physical sign is that the user-slot digit is gone from the display.

Not every model does this

Reported for the BF7xx family. A BF915 keeps its slots across a battery change: after one, beurer_register_new_user still came back with index 3, so slots 1 and 2 and the menu profile were all still there. On that model only a factory reset (CLr) clears them, so if your slots survived, the empty-scale recipe below is not your problem.

On a scale in that state:

  1. Try users[].beurer_pin: 0. A scale with no users has no code to check.
  2. Set users[].beurer_provision: true so the profile in config.yaml (date of birth, gender, height, activity level) is written back into the scale instead of an empty profile being confirmed to it.
yaml
users:
  - name: Alice
    beurer_pin: 0
    beurer_provision: true

One caution: on Linux, beurer_pin is also the passkey offered to BlueZ during pairing. If the scale is already bonded, remove the bond (bluetoothctl remove <MAC>) before changing it.

Provisioning only fills fields the scale reports as empty. A populated profile is written back byte for byte, exactly as before, and the commit line then reports 0 from config. There is no option to overwrite a record the scale already holds; a factory reset is the only way to change one.

Both get called "the PIN", and mixing them up costs hours.

DigitsWhat it isWhere it goes
Passkey6BLE Numeric Comparison during pairing. New every time.Nowhere. Confirm it on the scale with SET.
Consent code4SIG User Control Point, tied to one user slot.users[].beurer_pin

On a BF915 the consent code is the four-digit number the scale displays when you select that profile in its own menu. It does not have to be guessed or assigned.

Beurer BF 405 / BF 915: factory reset and deleting one user

Both live on the UNIT button on the underside, and differ only in how long you hold it:

  • 4 seconds with a user selected deletes that user (dEL).
  • 5 seconds from powered on resets the scale (CLr).

After a reset the scale asks for the date and time, which is a useful confirmation that it actually happened.

Beurer: every connect fails after the first session

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

The scale has dropped its half of the pairing while the host still holds the key. BlueZ replays the dead key forever and never invalidates it, so nothing recovers on its own. Clear it and pair again:

bash
sudo bluetoothctl
remove AA:BB:CC:DD:EE:FF
pair AA:BB:CC:DD:EE:FF

If it happens every session, ble.auto_clear_stale_bond: true does that for you. See the configuration reference.

Exporter Issues

Garmin upload fails

  • Re-run the setup wizard or npm run setup-garmin to refresh tokens.
  • Check that your Garmin credentials are correct.
  • Garmin may block requests from cloud/VPN IPs. Try authenticating from a different network, then copy ~/.garmin_tokens/ to your target machine.

MQTT connection hangs or fails

  • Make sure you're using the right protocol: mqtt:// for plain, mqtts:// for TLS. Using mqtt:// on a TLS port (8883) will hang.
  • Check your broker URL, username, and password.

Why the next scan is not immediate

In continuous mode the wait between scans depends on how the previous cycle ended:

  • A reading arrived: runtime.scan_cooldown (default 30 s), and at least 25 s on Linux/BlueZ regardless, so the app does not reconnect while the scale is still advertising.
  • No scale found, radio healthy: runtime.idle_rescan_delay (default 5 s). Linux/BlueZ only; the other transports cannot tell this case from a failure and use the backoff below.
  • The cycle failed (a GATT error, a wedged controller, an export that threw): 5 s, then 10, 20, 40 and 60 s for as long as failures continue, resetting on the next success.

So a gap of a minute between scans means failures, not a setting being ignored. The log line says which case it was:

No scale found, rescanning in 5s... (Device not found)
No scale found, retrying in 20s... (le-connection-abort-by-local)

Debug Mode

Set debug: true in config.yaml or use the environment variable to see detailed BLE logs:

bash
# Docker
docker run ... -e DEBUG=true ghcr.io/kristianp26/ble-scale-sync:latest

# Linux / macOS
DEBUG=true npm start

# Windows (PowerShell)
$env:DEBUG="true"; npm start

This shows BLE discovery details, advertised services, discovered characteristics, and UUID matching.

Capturing every frame, for a scale that is not decoded correctly

debug: true logs what the app decided. When a scale is misread, or a new one needs a protocol, what is needed is what the scale actually sent, including the frames the adapter rejected:

bash
BLE_RAW_CAPTURE=true DEBUG=true npm start

# Docker
docker run ... -e BLE_RAW_CAPTURE=true -e DEBUG=true ghcr.io/kristianp26/ble-scale-sync:latest

Every notification is then logged as hex with the characteristic it arrived on, whether the adapter understood it or not:

[BLE] [RAW] 0000fff100001000800000805f9b34fb (13B): 1f 05 00 02 e6 12 ...

It also holds the connection open for 20 seconds past the settled weight, because several scales send their body-composition frames after the number stops moving and the app would otherwise disconnect first. Change that window with BLE_RAW_CAPTURE_HOLD_SEC:

bash
BLE_RAW_CAPTURE=true BLE_RAW_CAPTURE_HOLD_SEC=40 DEBUG=true npm start

Step on the scale once, wait for it to disconnect on its own, and attach the whole log to the issue. Say what the scale displayed and what the vendor app recorded for the same weigh-in: a capture with known values is what makes a field decodable, and one without them usually is not.

Both are off by default and neither changes what is exported.

Install Issues (npm / npx)

ble-scale-sync: command not found

The package was installed into a project rather than globally, or the npm global bin directory is not on your PATH. Either run it through npx, which needs no PATH entry:

bash
npx ble-scale-sync --version

or install it globally and check where npm puts the binaries:

bash
npm install -g ble-scale-sync
npm prefix -g       # Windows: the shims sit here; Linux/macOS: in its bin/ subdirectory

That directory has to be on your PATH. (npm bin -g printed it in older npm versions and was removed in npm 9.)

No configuration found even though config.yaml exists

config.yaml is read from the directory you run the command in, not from the package. Run the command from the directory holding your config, or pass the path explicitly:

bash
ble-scale-sync --config /path/to/config.yaml
ble-scale-sync validate --config /path/to/config.yaml

Note that .env follows config.yaml: both are read from the same directory. See Where config.yaml and .env are read from.

BLE transport ... needs the npm package ...

The BLE stacks are optional dependencies, so the install completed without the one you selected (usually because @abandonware/noble compiles from source and the host has no C++ toolchain). The message names the package, the install command and the transports that still work on your host.

Install the stack where the app itself lives, not into your working directory:

bash
npm install -g @stoprocent/noble       # global ble-scale-sync install

Under npx the cache directory is thrown away between runs, so install the app itself instead: npm install -g ble-scale-sync @stoprocent/noble. The quickest way out on Windows is usually the prebuilt driver, ble.noble_driver: stoprocent, which needs no toolchain at all.

Platform Issues

Install fails on Raspberry Pi Zero W (first gen)

The original Pi Zero W has an ARMv6 CPU, which is not supported. The esbuild binary (used by the TypeScript runner tsx) requires ARMv7 or later and will crash with SIGILL (illegal instruction) on ARMv6. This affects both native installs and Docker.

Solution: Use a Raspberry Pi Zero 2W (~15€, ARMv7/64-bit) or any Pi 3/4/5.

Docker Issues

Container exits at startup with DBusError and AccessDenied

The log names An AppArmor policy prevents this sender from sending this message, mentions member="Hello", and the container exits before any scanning begins.

The host's D-Bus daemon asks the kernel whether the container may talk to org.freedesktop.DBus. Docker's docker-default AppArmor profile carries no D-Bus rules, and that mediation is deny by default, so the handshake never completes. Ubuntu 24.04 hits this most often because its AppArmor 4.x stack tightened the defaults. Bind-mounting the D-Bus socket is not enough on those hosts. Run the container unconfined:

bash
docker run --security-opt apparmor=unconfined ...

Or in docker-compose.yml:

yaml
services:
  ble-scale-sync:
    security_opt:
      - apparmor=unconfined

The Home Assistant add-on sets the equivalent option in its manifest, so it needs no action. Reported in #271.

Container can't find BLE adapter

Make sure you're passing all required flags. See Getting Started for the full command. The most common mistake is forgetting --network host or the D-Bus volume mount.

Wrong Bluetooth group GID

The --group-add value must match your system's Bluetooth group. Find it with:

bash
getent group bluetooth | cut -d: -f3

Common values: 112 (Debian/Ubuntu), 103 (Raspberry Pi OS), 108 (Arch).

The documented commands resolve this for you with --group-add "$(getent group bluetooth | cut -d: -f3)". If that produced nothing, the host has no bluetooth group at all, and docker run will say so rather than failing later with an unclear D-Bus error.

BLE discovery stops working after hours (BlueZ stuck state)

Symptoms (visible with DEBUG=true):

  • Repeated startDiscovery failed: Discovery already in progress and D-Bus StopDiscovery failed: No discovery started
  • Or Discovery started logs succeed, but the scale is never found even after stepping on it
  • Common on Raspberry Pi 3 / 4 / Zero 2W with the on-board Broadcom adapter under continuous-mode load

Cause. A known BlueZ bug (also tracked at bluez/bluer#47): after repeated GATT connect/disconnect cycles, BlueZ's Discovering property desyncs from the HCI controller. The daemon reports active discovery, but the controller is no longer running LE scan.

Hardware/firmware limitation, not just software

On Pi 3/4 Broadcom on-board chips, this is a kernel/firmware-level issue that even much larger projects have given up on fixing in software. See home-assistant/operating-system#4022 and home-assistant/core#142656, both closed as Not Planned with HA recommending a Bluetooth proxy as the workaround. The recovery tiers below clear the wedge on most setups but not all of them.

Recommended long-term fix: Bluetooth proxy. The most reliable way to run BLE Scale Sync on a Pi long-term is to bypass the on-board Bluetooth entirely. Run an external ESP32 BLE proxy (≈€8 board, communicates over MQTT) or reuse an existing ESPHome BT proxy. Both eliminate the host BlueZ stack from the BLE path completely.

Automatic in-process recovery. The app already:

  • Resets its D-Bus client after every GATT operation in continuous mode
  • Runs a preemptive btmgmt power off/on cycle after every GATT operation to clear zombie controller state before it accumulates
  • Escalates through 6 recovery tiers when StartDiscovery fails (D-Bus StopDiscovery, adapter power-cycle, btmgmt reset, rfkill block/unblock, systemctl restart bluetooth)

Auto-restart watchdog (continuous mode). When in-process recovery is not enough (typically Pi 3/4 Broadcom firmware lock-up), a watchdog exits the process after runtime.watchdog_max_consecutive_failures consecutive scan failures (default 10, ≈30 min). With Docker restart: unless-stopped the container restarts cleanly, the entrypoint resets the BT adapter, and the controller is typically unwedged. The watchdog only arms after the first successful weigh-in in the process lifetime, so it does not restart-loop the container if the scale is offline (vacation) or scale_mac is misconfigured.

The watchdog counts only cycles where the Bluetooth radio looks unhealthy: a connection or read failure, or a scan that saw no advertisement traffic at all (the zombie-discovery wedge). A normal idle cycle, where the radio still hears other nearby devices but your scale simply is not being stood on, does not count toward a restart. This is why a scale that only advertises while in use (such as Renpho) no longer triggers needless restarts overnight.

The watchdog recovers by exiting the process - set a restart policy

The recovery is the process exiting so the supervisor starts it again. You must run with restart: unless-stopped (Compose) or --restart unless-stopped (docker run). Without a restart policy the container just stops.

A Docker/Compose restart: policy fires only on process exit. It does not act on the HEALTHCHECK going unhealthy. If you see the container stuck Up (unhealthy) but never restarting, plain Docker is working as designed: only Swarm, Kubernetes, an autoheal sidecar, or the Home Assistant Supervisor (via the add-on watchdog toggle, which is on by default) restarts on health status. With a restart policy and the hard-exit floor below, the process always exits, so the policy always fires.

To bound the rare case where a wedged D-Bus/BlueZ handle pins the event loop open and graceful shutdown cannot drain (the process logs Stopped. but never exits, so the restart policy never fires), the app force-exits 5s after any abort-driven shutdown. Override with BLE_HARD_EXIT_GRACE_MS (1000–60000 ms) if your cleanup legitimately needs longer.

yaml
runtime:
  watchdog_max_consecutive_failures: 10 # default; 0 = disabled
bash
# Or env override
docker run ... -e BLE_WATCHDOG_MAX_FAILURES=10 ghcr.io/kristianp26/ble-scale-sync:latest

Docker compose tip. Make sure /dev/rfkill is mapped so Tier 5 recovery is available:

yaml
devices:
  - /dev/rfkill:/dev/rfkill

Container healthcheck (Docker HEALTHCHECK). The image reports healthy while /tmp/.ble-scale-sync-heartbeat has been written within the last 5 minutes. The app touches that file every 30 s for as long as the process is alive, independently of whether anyone is weighing in, so an idle scale never makes the container look unhealthy.

Scope of this healthcheck

Same contract as the systemd watchdog below: it only catches whole-loop freezes. A frozen event loop cannot run the 30 s timer either, so the file goes stale exactly when it should. If the event loop is alive but a specific BLE handler is wedged, the heartbeat keeps ticking; that case belongs to runtime.watchdog_max_consecutive_failures and the hard-exit floor above.

Home Assistant add-on restarts every ~7 minutes. Fixed in versions after 1.20.0. On 1.20.0 and earlier the heartbeat file was only touched when a reading arrived, so on HAOS any idle gap longer than 5 minutes flipped the container to unhealthy and the Supervisor watchdog restarted the add-on, forever. Symptom in the Supervisor log: Watchdog found app BLE Scale Sync is unhealthy, restarting... repeating on a fixed cadence, with the add-on log showing only clean startup and shutdown cycles. Workaround on affected versions: turn off the add-on's Watchdog toggle. Upgrading is the real fix.

Systemd watchdog (Type=notify). Defense in depth for the rare case where a synchronous D-Bus stall freezes the Node event loop entirely. When that happens the in-process watchdog cannot fire either, since setTimeout callbacks never run. Letting systemd do the liveness check from outside the process is the clean fix.

Scope of this watchdog

Only catches whole-loop freezes. If the event loop is alive but a specific BLE handler is wedged (e.g. an await that never resolves), the heartbeat keeps firing and systemd is satisfied. For BLE-specific recovery, rely on runtime.watchdog_max_consecutive_failures and the consecutive-failure watchdog above.

Add the following to your ble-scale.service unit on the Pi (or any systemd host):

[Service]
Type=notify
WatchdogSec=60
NotifyAccess=main
Restart=on-failure
RestartSec=5

The app sends READY=1 once at startup and WATCHDOG=1 every WatchdogSec / 2 seconds. If the heartbeat misses for WatchdogSec, systemd kills and restarts the unit. The integration is a no-op when $NOTIFY_SOCKET is unset (Docker, npm start, non-systemd installs), so the same binary works everywhere.

Last-resort escape hatch: switch away from BlueZ. If BlueZ keeps getting stuck despite the above, bypass it entirely by using the @stoprocent/noble driver (HCI socket directly, no D-Bus):

yaml
ble:
  noble_driver: stoprocent

Or set NOBLE_DRIVER=stoprocent as an environment variable. Trade-off: the app takes exclusive HCI access, so you cannot run bluetoothctl, Home Assistant's Bluetooth integration, or any other BLE consumer on the same adapter at the same time.

On the host you can also verify BlueZ state manually:

bash
bluetoothctl show | grep Discovering
sudo systemctl restart bluetooth   # manual recovery

Released under the GPL-3.0 License.