All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog.
- Renpho ES-26M: the 18-byte
0x12scale-info frame (where byte[1] is the packet length and bytes [2-7] carry the device MAC) was being misread as "byte[2] is the protocol type", yieldingproto=0xFFand causing every subsequent handshake command to be rejected by the scale. No0x10weight frames were ever returned. The QN-Scale adapter now detects the long-frame format (data.length >= 18 && data[1] === length) and falls through to the ES-30M code path withweightScaleFactor=10, so the existing heuristic auto-corrects the weight divisor. The unconditional skip ofR1=R2=0stable frames in ES-30M mode is also lifted: the ES-26M never reports impedance when the user is wearing socks, and those frames are the only stable reading available in that scenario. Contributed by @fromport (#128)
- ESPHome proxy: the handler now logs a one-time Phase 1 capability summary on connect, listing which configured scale adapters are broadcast-capable (produce readings on this transport) and which are GATT-only (will time out until Phase 2 / #116 ships). Users who were only seeing the generic
Timed out waiting for any recognized scale broadcast via ESPHome proxyline now immediately see whether their scale brand is in the broadcast-capable set, instead of having to reproduce the failure twice to catch the per-MAC warn. Surfaces the Yunmai / Beurer / Mi Scale 2 / etc. mismatch reported by @geniusliang in #133
- CONTRIBUTING.md: project structure tree refreshed to match the current layout (HA add-on, ESPHome proxy handler, updated scale/test files)
- README: contributors migrated to a GitHub-style table with inline avatars, @fromport added for the ES-26M contribution
- @fromport for diagnosing and fixing the ES-26M handshake end to end, including the heuristic weight-divisor path
- @geniusliang for the detailed ESPHome proxy repro that led to the capability-summary UX improvement
- ESPHome proxy: the
ReadingWatchersilently dropped advertisements from dual-mode scale adapters (parseBroadcastdefined andcharNotifyUuidset) when the broadcast frame was not weight-bearing. Reported by @deadhurricane on an Elis 1 / ES-30M, which matches by name as a QN-Scale but only beacons its MAC in manufacturer data and carries weight over GATT. The handler now warns once per MAC that the scale needs a GATT connection (Phase 2), pointing the user at the native BLE handler or the ESP32 MQTT proxy as workarounds instead of leaving them staring at a silent log (#116, #75) - Logger:
runtime.debug: trueinconfig.yamldid not switch the log level to DEBUG, only theDEBUG=trueenv var did. The app now honors the config value on startup, so HA Add-on users (who passdebugas an option, not an env var) and anyone driving the runtime fromconfig.yamlget the verbose BLE logs they expect
- Embedded MQTT broker for the ESP32 proxy: zero-config setup, no Mosquitto required. When
ble.mqtt_proxy.broker_urlis omitted, BLE Scale Sync now starts an embedded aedes broker on0.0.0.0:1883by default; the internal client connects over loopback, and the ESP32 firmware just points at the host machine's LAN IP. Port and bind interface are configurable viaembedded_broker_portandembedded_broker_bind, optional username/password are enforced when set. Existingbroker_urlsetups are untouched (#54) - ESPHome Bluetooth proxy transport (experimental, phase 1 / broadcast-only): third BLE handler option
ble.handler: esphome-proxy. If you already run an ESPHome Bluetooth proxy mesh for Home Assistant, BLE Scale Sync can connect to it over Native API (port 6053, optional Noise encryption or legacy password) and reuse it as its BLE radio, so no dedicated ESP32 with custom firmware and no MQTT broker are required. Phase 1 handles broadcast scales only; GATT scales log a warning and are skipped until phase 2 lands. Newdocs/guide/esphome-proxy.mdcovers setup, encryption key handling and limitations (#116) - Setup wizard: new "Use built-in embedded broker" option for the mqtt-proxy handler, so new installs skip the external broker prompt by default. Handler selection now also includes "Via ESPHome Bluetooth proxy" with prompts for host, port and authentication mode
- Embedded MQTT broker: configs that bind the embedded broker to a non-loopback interface (default
0.0.0.0) now requireusername+passwordand are rejected at schema validation time. The wizard defaults to requiring auth and falls back to127.0.0.1when the user declines, so misconfiguration cannot silently expose a LAN-reachable broker without credentials. - ESPHome proxy: configs that set both
encryption_keyandpasswordare rejected at schema validation time. Pick Noise (encryption_key, recommended) or legacy password, not both.
- Eufy Smart Scale P2 (T9148) and P2 Pro (T9149): new dedicated adapter with the AES-128-CBC C0/C1/C2/C3 handshake required by these models. Weight + impedance over GATT FFF2 after authentication, passive weight reading from the 19-byte advertisement without connecting. Prevents the prior false match as a QN scale that crashed with
Operation is not supportedon FFF1 (#98)
- Setup wizard: picking no exporter in the export-targets checkbox silently produced a config without any
global_exportersblock, so the first run emittedAll exports failedand exited with code 1. The wizard now asks an explicitContinue without exporters?confirmation when the checkbox is submitted empty and re-prompts if the user declines (#98) - Orchestrator:
dispatchExports([])loggedAll exports failedbecauseallFaileddefaulted totruewith zero iterations. Empty exporter lists now short-circuit with a clear warning (No exporters configured — measurement processed but not sent anywhere) and returnsuccess, so single-shot mode no longer exits with code 1 when the config has no exporters
- @mart1058 and @dbrb2 for diagnose output, HCI snoop logs, and testing the Eufy P2 Pro protocol reverse-engineering (#98)
- bdr99/eufylife-ble-client for the reference Python implementation of the Eufy T9148/T9149 auth handshake and frame formats
- Sanitas SBF70 / Beurer BF710 family: weight parsed as a stuck
12.80 kgregardless of the real reading on the scale. Root cause: the BF710 variant (start byte0xE7) sends a compact 5-byte0x58frame with weight at bytes[3-4]BE, not the 6+ byte BF700/BF800 layout the adapter assumed. The adapter rejected every live weight frame as too short and then mis-parsed the0x59finalize frame. Now branches onisBf710Typeand applies a 3-reading stability window (0.3 kg tolerance) so the scale's initial metadata frame does not trigger early completion (#112)
- Garmin: upload failed with
'Garmin' object has no attribute 'garth'aftergarminconnectreleased 0.3.0 on 2026-04-02, which dropped thegarthdependency in favor of native authentication. The Python bridge accessedgarmin.garth.sess.headersandgarmin.garth.dump(), both removed in 0.3.x. Migrated to the new API:Garmin.login(tokenstore)auto-persists on successful credential login, andclient.dump(token_dir)saves tokens after MFA. Custom User-Agent override is no longer needed becausegarminconnectnow usescurl_cffiTLS impersonation and randomized browser fingerprints internally (#114) - Docker: added
libcurl4-openssl-devsocurl_cffi(new transitive dep viagarminconnect0.3.x) builds from source on armv7, where PyPI has no prebuilt wheel
- Tokens from
garminconnect0.2.x (old garth OAuth1/OAuth2 files) are incompatible with 0.3.x. Existing installs must re-authenticate:npm run setup-garmin, or in the HA Add-on just restart the add-on so it re-runs setup from the credentials you entered. The setup script auto-removes leftoveroauth*_token.jsonfiles before writing the new token format.
- @Phipseyy and @mooredav87 for reporting the Garmin upload regression (#114)
- HA Add-on: one-click install via a My Home Assistant badge in the README, landing page, getting-started guide, and HA Add-on guide. Manual steps remain as a fallback for users without My Home Assistant configured
- HA Add-on:
weight_unitandheight_unitexposed as add-on options (kg/lbs, cm/in). The CLI and exporters display in the chosen unit while internal math stays in kg/cm - HA Add-on:
last_known_weightpersists across restarts. The runtime config lives at/data/config.yamland a small Python helper (merge_last_weights.py) copies preserved per-user weights from the previous run into the freshly generated config on every startup, so multi-user identification by weight stays accurate after reboots and add-on updates - Docs: new Home Assistant Add-on guide covering install, full configuration reference, MQTT auto-detection, Garmin setup (including the MFA and IP-block workarounds), custom config mode, persistence semantics, and troubleshooting. Promoted to a first-class quick-start in the README and landing page
- HA Add-on: Garmin Connect uploads now work out of the box. The add-on previously created an empty
/data/garmin-tokens/directory and never ran the authentication step, so the first upload always failed withNo such file or directory: '/data/garmin-tokens/oauth1_token.json'. On first start the add-on now runssetup_garmin.py --from-configto generate OAuth tokens from the email and password you entered in the UI (#111) - Docker: armv7 image builds failed because
cffi(transitive dep viagarminconnect) had no pre-built wheel for armv7 + Python 3.11 and pip could not compile from source. Addedpython3-dev,libffi-dev, andlibssl-devto the image so cffi builds cleanly
- HA Add-on: MFA-friendly token import. If your Garmin account uses 2FA, drop pre-generated
oauth1_token.jsonandoauth2_token.jsonfiles into/share/ble-scale-sync/garmin-tokens/and the add-on imports them on startup, skipping the interactive auth that has no terminal inside an add-on container - HA Add-on: DOCS.md now explains the full Garmin setup flow including the MFA workaround and the IP-block workaround
- QN Scale: rewrote adapter as a notification-driven state machine for newer firmware (Renpho Elis 1, ES-CS20M) that requires an AE00 service handshake before measurement data flows (#75, #84)
- QN Scale: added ES-30M weight frame format detection (different byte layout for weight and impedance)
- QN Scale: 0x13 config byte now sends 0x01 (kg) instead of 0x08, which was switching the scale display to lb
- QN Scale: 2-second fallback timer for Linux (BlueZ D-Bus) where the initial 0x12 frame may be lost due to a CCCD subscription race condition
- QN Scale: skip impedance-less stable frames on ES-30M so the adapter waits for the full body composition reading
- @DJBenson for extensive macOS testing, packet capture analysis, and reverse-engineering the state machine flow (#84)
- @ericandreani for persistent Linux/Docker testing across multiple iterations (#75)
- Docker:
diagnosecommand was missing from the entrypoint, causing "exec: diagnose: not found" when runningdocker run ... diagnose <MAC>(#98)
- QN Scale: UUID fallback (FFF0/FFE0) no longer matches named devices from other brands. Prevents Eufy, 1byone, and similar scales that share the FFF0 service from being incorrectly identified as QN Scale and failing with "Operation is not supported" (#98)
- Update check: replaced strict 24-hour cooldown with calendar-day (UTC) comparison. Users who weigh in slightly earlier each day (e.g. 7:00 AM, then 6:55 AM) were being skipped
1.7.0 - 2026-03-29
- Update check with anonymous usage statistics (#87). After each successful measurement (max once per 24h), the app checks
api.blescalesync.devfor newer versions. Only the app version, OS, and architecture are sent via the User-Agent header. Disable withupdate_check: falsein config.yaml. Automatically disabled in CI environments - Cloudflare Worker (
worker/) serving the/versionendpoint and a public stats dashboard at stats.blescalesync.dev with aggregated anonymous data (version distribution, OS/architecture breakdown) - Setup wizard shows an update notice before the first step if a newer version is available
- CI: GitHub Actions workflow for automatic Cloudflare Worker deployment on push to main (
worker.yml)
1.6.4 - 2026-03-27
- BLE: use ATT Write Request instead of Reliable Write in node-ble handler, fixing "Operation is not supported" errors on Medisana BS430 and similar scales that do not support reliable writes (#85)
- BLE: GATT characteristic flags are now logged during discovery (
DEBUG=true) for easier troubleshooting
1.6.3 - 2026-03-04
- Docker: removed cleanup workflow that was deleting multi-arch platform manifests, making all Docker images unpullable (#74, #76)
- @marcelorodrigo for reporting the broken Docker images (#74)
- @mtcerio for the additional report (#76)
1.6.2 - 2026-03-02
- CI: Docker
latesttag now only applies to GitHub releases, not every push to main (#70) - CI: Removed push-to-main Docker build trigger (#71)
- Docs: SEO meta keywords added to all documentation pages (#69)
- Docs: Alternatives page updated with Strava, file export, and ESP32 proxy sections (#68)
- Docs: ESP32 BLE proxy section added to getting started guide (#67)
1.6.1 - 2026-03-01
- BlueZ stale discovery recovery after Docker container restart. Adds kernel-level adapter reset via
btmgmtas Tier 4 fallback when D-Bus recovery fails, plus proactive adapter reset in Docker entrypoint (#39, #43)
- CI: Docker cleanup workflow removes PR images and untagged versions from GHCR (#58)
- Docs: Contributors section added to README
- Node.js: minimum version bumped to 20.19.0 (required by eslint 10.0.2)
- Deps: @stoprocent/noble 2.3.16, eslint 10.0.2, typescript-eslint 8.56.1, @types/node 25.3.3, @inquirer/prompts 8.3.0
- @marcelorodrigo for reporting the stale BlueZ discovery issue (#39)
1.6.0 - 2026-02-28
- ESP32 BLE proxy (experimental) for remote BLE scanning over MQTT. Use a cheap ESP32 board (~8€) as a wireless Bluetooth radio, enabling BLE Scale Sync on machines without local Bluetooth. Supports both broadcast and GATT scales
- ESP32 display board (Guition ESP32-S3-4848S040) with LVGL UI showing scan status, user matches, and export results
- Beep feedback on ESP32 boards with I2S buzzer (Atom Echo) when a known scale is detected
- Streaming BLE scan for ESP32-S3 boards with hardware radio coexistence
- Docker mqtt-proxy compose (
docker-compose.mqtt-proxy.yml) requiring no BlueZ, D-Bus, orNET_ADMIN - Setup wizard includes interactive mqtt-proxy configuration
BLE_HANDLER=mqtt-proxyenvironment variable as alternative to config.yaml- ESP32 proxy documentation page with architecture diagram, flashing guide, and MQTT topics reference
- Renpho broadcast parsing consolidated into QN scale adapter
- Landing page updated with ESP32 proxy and Setup Wizard feature cards
1.5.0 - 2026-02-24
- File exporter (CSV/JSONL) for local measurement logging without external services. Auto-header CSV with proper escaping, JSONL format, per-user file paths, and directory writability healthcheck
- Strava exporter with OAuth2 token management. Updates athlete weight via PUT /api/v3/athlete. Automatic token refresh, restricted file permissions (0o600), and interactive setup script (
npm run setup-strava) - Strava API application setup guide in documentation with step-by-step instructions
1.4.0 - 2026-02-24
- BLE diagnostic tool (
npm run diagnose) for detailed device analysis: advertisement data, service UUIDs, RSSI, connectable flag, and step-by-step GATT connection testing - Broadcast mode for non-connectable QN-protocol scales (#34). Reads weight directly from BLE advertisement data without requiring a GATT connection
- Garmin 2FA/MFA support in
setup_garmin.py. Prompts for authenticator code when Garmin requires multi-factor authentication
- QN broadcast parser: corrected byte layout (LE uint16 at bytes 17-18, stability flag at byte 15). Previous layout produced incorrect weight values
- ES-CS20M: service UUID 0x1A10 fallback for unnamed Yunmai-protocol devices (#34)
- ES-CS20M: 0x11 STOP frame support as stability signal (#34)
- CI: Node.js 24 added to test matrix (required check)
- CI: PR-triggered Docker image builds with
pr-{id}tags (#44) - Deps: ESLint v10, typescript-eslint v8.56
- @APIUM for Garmin 2FA support (#41)
- @Tosiman-Global and @BenBaril83 for debugging the ES-CS20M broadcast protocol (#34)
- @marcelorodrigo for PR-triggered Docker builds (#44)
1.3.0 - 2026-02-16
- Garmin multi-user Docker authentication —
setup-garmin --user <name>and--all-userscommands setup_garmin.py --from-configmode reads users and credentials fromconfig.yaml--token-dirargument forgarmin_upload.pyandsetup_garmin.py— per-user token directories- Tilde expansion for
token_dirin TypeScript exporter - 4 new Garmin exporter tests (token_dir passing, tilde expansion, backward compat)
pyyamldependency for config.yaml parsing in Python scripts- Docker multi-user volume examples in
docker-compose.example.ymland docs
- Friendly error message when D-Bus socket is not accessible (missing
-v /var/run/dbus:/var/run/dbus:roin Docker) instead of rawENOENTcrash (#25)
- Wizard passes Garmin credentials via environment variables instead of CLI arguments (security)
- @marcelorodrigo for #29 — the initial implementation that inspired this solution
1.2.2 - 2026-02-14
- Annotated
config.yaml.examplewith all sections and exporters CONTRIBUTING.md— development guide, project structure, test coverage, adding adapters/exporters, PR guidelinesCHANGELOG.md- GitHub Release and TypeScript badges
- Documentation split into
docs/— exporters, multi-user, body-composition, troubleshooting
- Rewrite README (~220 lines, Docker-first quick start, simplified scales table)
- Move dev content (project structure, test coverage, adding adapters/exporters) into CONTRIBUTING.md
.env.examplenow notes thatconfig.yamlis the preferred configuration method
1.2.1 - 2026-02-13
- Docker support with multi-arch images (
linux/amd64,linux/arm64,linux/arm/v7) Dockerfile,docker-entrypoint.sh,docker-compose.example.yml- GitHub Actions workflow for automated GHCR builds on release
- Docker health check via heartbeat file
1.2.0 - 2026-02-13
- Interactive setup wizard (
npm run setup) — BLE discovery, user profiles, exporter configuration, connectivity tests - Edit mode — reconfigure any section without starting over
- Non-interactive mode (
--non-interactive) for CI/automation - Schema-driven exporter prompts — new exporters auto-appear in the wizard
1.1.0 - 2026-02-13
- Multi-user support — weight-based user matching (4-tier priority)
- Per-user exporters (override global for specific users)
config.yamlas primary configuration format (.envfallback preserved)- Automatic
last_known_weighttracking (debounced, atomic YAML writes) - Drift detection — warns when weight approaches range boundaries
unknown_userstrategy (nearest,log,ignore)- SIGHUP config reload (Linux/macOS)
- Exporter registry with self-describing schemas
- Multi-user context propagation to all 5 exporters (MQTT topic routing, InfluxDB tags, Webhook fields, Ntfy prefix)
1.0.1 - 2026-02-13
- Configuration is now
config.yaml-first with.envas legacy fallback - README rewritten for
config.yamlworkflow
1.0.0 - 2026-02-12
- Initial release
- 23 BLE scale adapters (QN-Scale, Xiaomi Mi Scale 2, Yunmai, Beurer, Sanitas, Medisana, and more)
- 5 export targets: Garmin Connect, MQTT (Home Assistant), Webhook, InfluxDB, Ntfy
- BIA body composition calculation (10 metrics)
- Cross-platform BLE support (Linux/node-ble, Windows/@abandonware/noble, macOS/@stoprocent/noble)
- Continuous mode with auto-reconnect
- Auto-discovery (no MAC address required)
- Exporter healthchecks at startup
- 894 unit tests across 49 test files