Thank you for your interest in contributing! This guide covers everything you need to get started.
- Node.js v20 or later
- Python 3.9 or later (only needed for Garmin upload)
- A Bluetooth Low Energy (BLE) capable adapter (for testing with real hardware)
# Clone and install
git clone https://github.com/KristianP26/ble-scale-sync.git
cd ble-scale-sync
npm install
# Python venv (only for Garmin exporter)
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install -r requirements.txt| Branch | Purpose |
|---|---|
main |
Stable release branch |
dev |
Active development — PRs and new features target this branch |
CI runs on both main and dev (push + pull request).
npm test # Run all tests (Vitest)
npx vitest run tests/exporters/mqtt.test.ts # Single fileUnit tests use Vitest and cover:
- Body composition math —
body-comp-helpers.ts - Config schemas — Zod validation, defaults, error formatting, slug generation
- Config loading — YAML parsing, env reference resolution, config source detection, BLE config loader, env overrides
- Config resolution — user profile resolution, runtime config extraction, exporter merging, single-user convenience
- Config writing — atomic file write, write lock serialization, YAML comment preservation, debounced weight updates
- User matching — 4-tier weight matching, all strategies (nearest/log/ignore), overlapping ranges, drift detection
- Environment validation —
validate-env.ts(all validation rules and edge cases) - Scale adapters —
parseNotification(),matches(),isComplete(),computeMetrics(), andonConnected()for all 25 adapters - Exporters — config parsing, MQTT publish/HA discovery, MQTT multi-user topic routing + per-user HA discovery, Garmin subprocess, Webhook/InfluxDB/Ntfy delivery, ExportContext, ntfy drift warning
- Multi-user flow — matching → profile resolution → exporter resolution → ExportContext construction, strategy fallback, tiebreak with last_known_weight
- Orchestrator — healthcheck runner, export dispatch, parallel execution, partial/total failure handling
- BLE shared logic —
waitForRawReading()andwaitForReading()in legacy, onConnected, and multi-char modes; weight normalization; disconnect handling - BLE utilities —
formatMac(),normalizeUuid(),sleep(),withTimeout(), abort signal handling - Logger —
createLogger(),setLogLevel() - Utilities — shared retry logic (
withRetry), error conversion (errMsg) - Setup wizard — runner (step ordering, back navigation, edit mode), user profile prompts (validation, lbs→kg conversion, slug generation), exporter schema-driven field rendering, non-interactive mode (validation + slug enrichment), platform detection (OS, Docker, Python)
npm run lint # ESLint check
npm run lint:fix # ESLint auto-fix
npm run format # Prettier auto-format
npm run format:check # Prettier check (CI)- ES Modules —
"type": "module"inpackage.json; imports use.jsextension (TypeScript with Node16 module resolution) - TypeScript strict mode — target ES2022, module Node16
- Prettier — semicolons, single quotes, trailing commas, 100 char width
- ESLint — typescript-eslint recommended; unused vars prefixed with
_are allowed
Both ESLint and Prettier are enforced in CI.
ble-scale-sync/
├── .github/
│ └── workflows/
│ ├── ci.yml # CI: lint, format, typecheck, tests (Node 22/24/26), python-check
│ ├── docker.yml # Docker: multi-arch build + GHCR push on release
│ ├── docker-cleanup.yml # Prune old GHCR image tags
│ ├── docs.yml # VitePress docs deploy to Cloudflare Pages
│ └── worker.yml # Deploy stats Cloudflare Worker
├── src/
│ ├── index.ts # Entry point (single/multi-user flow, SIGHUP reload, heartbeat)
│ ├── orchestrator.ts # Export dispatch, healthchecks, partial/total failure handling
│ ├── diagnose.ts # npm run diagnose (BLE troubleshooting tool)
│ ├── scan.ts # BLE device scanner utility (npm run scan)
│ ├── logger.ts # createLogger, setLogLevel (structured logging)
│ ├── update-check.ts # Optional anonymous version check + stats ping
│ ├── validate-env.ts # .env validation & typed config loader (legacy path)
│ ├── config/
│ │ ├── schema.ts # Zod schemas (AppConfig, UserConfig) + WeightUnit
│ │ ├── load.ts # Unified config loader (YAML + .env fallback)
│ │ ├── resolve.ts # Config → runtime types (UserProfile, exporters)
│ │ ├── validate-cli.ts # CLI entry for npm run validate
│ │ ├── slugify.ts # Slug generation + uniqueness validation
│ │ ├── user-matching.ts # Weight-based multi-user matching (4-tier)
│ │ └── write.ts # Atomic YAML write + debounced weight updates
│ ├── ble/
│ │ ├── index.ts # OS detection + handler barrel (scanAndRead, scanAndReadRaw)
│ │ ├── types.ts # ScanOptions, ScanResult, constants, utilities
│ │ ├── shared.ts # BleChar/BleDevice abstractions, waitForReading()
│ │ ├── async-queue.ts # Async notification queue for GATT handlers
│ │ ├── loopback.ts # In-process loopback handler (tests)
│ │ ├── handler-node-ble/ # Linux native: node-ble (BlueZ D-Bus) (split: dbus, connection, discovery, freshness, connect, gatt, broadcast, scan)
│ │ ├── handler-noble-shared.ts # Shared Noble logic (driver injected via factory)
│ │ ├── handler-noble.ts # macOS native: @stoprocent/noble (thin entrypoint)
│ │ ├── handler-noble-legacy.ts # Windows native: @abandonware/noble (thin entrypoint)
│ │ ├── handler-mqtt-proxy/ # ESP32 proxy over MQTT (split: client, topics, gatt, scan, watcher, display)
│ │ ├── handler-esphome-proxy.ts # ESPHome BT proxy over Native API (phase 1, broadcast)
│ │ ├── embedded-broker.ts # Embedded aedes MQTT broker for ESP32 proxy
│ │ └── mqtt-proxy-bootstrap.ts # First-run scan + adapter pin for ESP32 proxy
│ ├── exporters/
│ │ ├── index.ts # Exporter factory: createExporters()
│ │ ├── registry.ts # Self-describing exporter registry (schemas + factories)
│ │ ├── config.ts # Exporter env validation + config parsing
│ │ ├── garmin.ts # Garmin Connect (Python subprocess)
│ │ ├── strava.ts # Strava OAuth2 (per-user only)
│ │ ├── strava-setup.ts # One-time Strava OAuth (npm run setup-strava)
│ │ ├── mqtt.ts # MQTT + Home Assistant auto-discovery
│ │ ├── webhook.ts # Generic HTTP webhook
│ │ ├── influxdb.ts # InfluxDB v2 (line protocol)
│ │ ├── ntfy.ts # Ntfy push notifications
│ │ ├── telegram.ts # Telegram bot notifications
│ │ ├── intervals.ts # Intervals.icu wellness records
│ │ └── file.ts # Local file exporter (CSV / JSONL)
│ ├── wizard/
│ │ ├── index.ts # Entry for npm run setup
│ │ ├── types.ts # WizardStep, WizardContext, PromptProvider
│ │ ├── runner.ts # Step sequencer (sequential + edit mode)
│ │ ├── non-interactive.ts # Non-interactive validation + slug enrichment
│ │ ├── platform.ts # OS/Docker/Python detection
│ │ ├── prompt-provider.ts # Real + mock prompt providers (DI)
│ │ ├── ui.ts # Banner, icons, section boxes, chalk helpers
│ │ └── steps/
│ │ ├── index.ts # Step registry (WIZARD_STEPS)
│ │ ├── welcome.ts # Banner + edit mode detection
│ │ ├── ble.ts # BLE scale discovery / manual MAC entry
│ │ ├── users.ts # User profile setup
│ │ ├── exporters.ts # Schema-driven exporter selection
│ │ ├── garmin-auth.ts # Garmin Connect authentication
│ │ ├── runtime.ts # Runtime settings (continuous, cooldown)
│ │ ├── validate.ts # Exporter connectivity tests
│ │ └── summary.ts # Config review + YAML save
│ ├── utils/
│ │ ├── retry.ts # Shared retry utility (withRetry)
│ │ └── error.ts # errMsg (unknown → string)
│ ├── interfaces/
│ │ ├── scale-adapter.ts # ScaleAdapter interface & shared types
│ │ ├── exporter.ts # Exporter interface & ExportResult
│ │ ├── exporter-schema.ts # ExporterSchema for self-describing exporters
│ │ └── display-notifier.ts # DisplayNotifier capability (transport-agnostic display/beep)
│ └── scales/
│ ├── index.ts # Adapter registry (order matters: generic last)
│ ├── body-comp-helpers.ts # Shared body-comp utilities
│ ├── qn-scale.ts # QN / Renpho (incl. ES-26M, ES-30M) / Senssun / Sencor
│ ├── renpho.ts # Renpho ES-WBE28
│ ├── renpho-es26bb.ts # Renpho ES-26BB-B
│ ├── mi-scale-2.ts # Xiaomi Mi Scale 2
│ ├── yunmai.ts # Yunmai Signal / Mini / SE
│ ├── eufy-p2.ts # Eufy P2 / P2 Pro (T9148/T9149, AES-128 handshake)
│ ├── beurer-sanitas.ts # Beurer BF700/710/800, Sanitas SBF70/75
│ ├── sanitas-sbf72.ts # Sanitas SBF72/73, Beurer BF915
│ ├── soehnle.ts # Soehnle Shape / Style
│ ├── medisana-bs44x.ts # Medisana BS430/440/444
│ ├── trisa.ts # Trisa Body Analyze
│ ├── es-cs20m.ts # ES-CS20M
│ ├── exingtech-y1.ts # Exingtech Y1 (vscale)
│ ├── excelvan-cf369.ts # Excelvan CF369
│ ├── hesley.ts # Hesley (YunChen)
│ ├── inlife.ts # Inlife (fatscale)
│ ├── digoo.ts # Digoo DG-SO38H (Mengii)
│ ├── senssun.ts # Senssun Fat
│ ├── one-byone.ts # 1byone / Eufy C1 / Eufy P1
│ ├── active-era.ts # Active Era BF-06
│ ├── mgb.ts # MGB (Swan / Icomon / YG)
│ ├── hoffen.ts # Hoffen BS-8107
│ └── standard-gatt.ts # Generic BCS/WSS catch-all
├── tests/
│ ├── body-comp-helpers.test.ts # Body-comp math
│ ├── validate-env.test.ts # .env validation
│ ├── orchestrator.test.ts # Healthchecks + export dispatch
│ ├── multi-user-flow.test.ts # Multi-user integration
│ ├── logger.test.ts # Logger utility
│ ├── update-check.test.ts # Update check + stats ping
│ ├── helpers/
│ │ └── scale-test-utils.ts # Mock peripheral + shared helpers
│ ├── wizard/ # Runner, users, exporters, non-interactive, platform
│ ├── config/ # Schema, slugify, load, resolve, write, matching
│ ├── ble/ # Shared logic, utilities, handlers, abort signal
│ ├── utils/ # Retry, error
│ ├── scales/ # One file per adapter (23 files)
│ └── exporters/ # config, garmin, mqtt (+multi-user), webhook, influxdb,
│ # ntfy, telegram, intervals, strava, file, context, healthcheck, registry, index
├── ble-scale-sync-addon/ # Home Assistant Supervisor Add-on
│ ├── Dockerfile # Thin layer on GHCR image + jq/curl/run.sh
│ ├── build.yaml # Multi-arch build config
│ ├── config.yaml # Add-on manifest (options schema, HA services, perms)
│ ├── run.sh # /data/options.json → config.yaml → app start
│ ├── merge_last_weights.py # Persist last_known_weight across restarts
│ ├── DOCS.md # Add-on user docs (shown in HA UI)
│ ├── CHANGELOG.md # Add-on version history
│ ├── icon.png, logo.png
│ └── translations/
├── firmware/ # ESP32 BLE proxy firmware (MicroPython)
│ ├── main.py, boot.py, ble_bridge.py
│ ├── board_*.py # Per-board pin/display setup (Atom Echo, S3, Guition)
│ ├── ui.py, beep.py, panel_init_*.py
│ ├── flash.sh # Board flashing helper
│ ├── requirements.txt, config.json.example
│ └── tools/
├── worker/ # Cloudflare Worker for stats.blescalesync.dev
│ ├── src/, wrangler.toml, package.json, tsconfig.json
├── garmin-scripts/
│ ├── garmin_upload.py # Garmin uploader (JSON stdin → JSON stdout)
│ └── setup_garmin.py # One-time Garmin auth setup
├── docs/ # VitePress site → blescalesync.dev
│ ├── index.md, exporters.md, multi-user.md, body-composition.md
│ ├── troubleshooting.md, changelog.md, alternatives.md, faq.md
│ ├── guide/
│ │ ├── getting-started.md, configuration.md, supported-scales.md
│ │ ├── home-assistant-addon.md, esp32-proxy.md, esphome-proxy.md
│ │ └── auto-update.md
│ └── public/, images/
├── drivers/ # Bundled vendor BLE drivers / helper binaries
├── repository.yaml # HA add-on repository manifest (one-click install)
├── config.yaml.example # Annotated config template
├── docker-compose.example.yml # Example Compose (native BLE)
├── docker-compose.mqtt-proxy.yml # Example Compose (ESP32 MQTT proxy)
├── Dockerfile # Multi-arch image (node:22-slim + BlueZ + Python)
├── docker-entrypoint.sh # Docker entrypoint (start/setup/scan/validate/help)
├── CONTRIBUTING.md # This file
├── CHANGELOG.md # Version history (Keep a Changelog format)
├── CODE_OF_CONDUCT.md
├── SECURITY.md
├── PORTING.md # Notes for porting adapters from openScale
├── .env.example # Legacy .env template (config.yaml preferred)
├── .prettierrc, eslint.config.js
├── tsconfig.json, tsconfig.eslint.json
├── .nvmrc, .gitattributes, .dockerignore, .gitignore
├── package.json, package-lock.json, requirements.txt
├── LICENSE
└── README.md
To support a new scale brand, create a class that implements ScaleAdapter in src/scales/:
- Create
src/scales/your-brand.tsimplementing the interface fromsrc/interfaces/scale-adapter.ts - Define
matches()to recognize the device by its BLE advertisement name - Implement
parseNotification()for the brand's data protocol - Register the adapter in
src/scales/index.ts— position matters (specific adapters must come before generic catch-all) - If your adapter detects the weight unit from BLE data and converts to kg internally, set
normalizesWeight = true - Add tests in
tests/scales/using mock utilities fromtests/helpers/scale-test-utils.ts
To add a new export target:
- Create
src/exporters/your-exporter.tsimplementing theExporterinterface fromsrc/interfaces/exporter.ts- Export an
ExporterSchemadescribing fields, display info, andsupportsGlobal/supportsPerUser - Accept optional
ExportContextinexport(data, context?)for multi-user support
- Export an
- Add the name to the
ExporterNametype andKNOWN_EXPORTERSset insrc/exporters/config.ts - Add env var parsing in
src/exporters/config.ts(for.envfallback path) - Add a case to the switch in
createExporters()insrc/exporters/index.ts - Add a registry entry in
src/exporters/registry.tswith{ schema, factory } - Add tests in
tests/exporters/(includingExportContextbehavior) - Document config fields in
README.mdand.env.example
-
Branch from
dev(notmain) -
All tests must pass:
npm test -
ESLint and Prettier must be clean:
npm run lint && npm run format:check -
TypeScript must compile:
npx tsc --noEmit -
Keep commits focused, one logical change per commit
-
PRs are squash-merged. The repo allows squash only; merge-commit and rebase-merge are disabled. On merge, GitHub squashes the branch into a single commit on the target branch and uses the PR title as the commit subject. That makes the PR title the thing release-please parses, so it MUST be in Conventional Commits format (see below). The PR body becomes the commit body.
-
Write commit messages, and your PR title, in Conventional Commits style. The project uses release-please to generate the changelog and version bumps, so the prefix you pick decides both whether the release notes mention the change and how the version bumps:
feat:orfeat(scope):new user-visible capability, bumps the minor versionfix:orfix(scope):bug fix, bumps the patch versionperf:performance improvement, bumps the patch versionrefactor:,docs:,chore:,ci:,test:,build:no version bump, appears in an "Other" / "Miscellaneous" section of the release notes- Append
!(for examplefeat(ble)!:) or include aBREAKING CHANGE:footer to bump the major version - Scopes commonly used in this repo:
ble,scales,exporters,wizard,config,docker,ci,docs, plus individual adapter names
[!IMPORTANT] Non-conforming commit messages are silently ignored by release-please and will not appear in the generated changelog. When in doubt, look at
git log --onelinefor recent examples.
Releases are fully automated via release-please. You do not create tags, edit CHANGELOG.md, or bump the version in package.json by hand.
- Merge feature / fix PRs into
devwith a Conventional Commit message. - When
devis ready to ship, open a PRdev→mainand merge it. - The
release-pleaseworkflow (.github/workflows/release-please.yml) runs on every push tomainand opens (or updates) a release PR titledchore(main): release vX.Y.Z. - That PR shows the proposed version bump plus the generated
CHANGELOG.mddiff. Review, optionally edit the PR body or the CHANGELOG entry in the PR to add prose (for example a### Thankssection, the one thing release-please does not generate by itself), then merge it with--adminlike any other release PR. - On merge, release-please tags the release (
vX.Y.Z), creates a GitHub Release, and emits therelease: publishedevent thatdocker.ymllistens for. The multi-arch image is published to GHCR automatically. VitePress rebuildsdocs/changelog.mdfromCHANGELOG.mdvia an@includedirective, so the public changelog updates too.
package.json(version field)package-lock.json(version field)ble-scale-sync-addon/config.yaml(version field, via the generic YAML updater + JSONPath$.version)CHANGELOG.md(generated from conventional commits since the previous tag).release-please-manifest.json(internal state, tracks the last released version)
Do not edit these files in a feature PR. If you need to correct the version or changelog, do it in the release PR before merging.
ble-scale-sync-addon/CHANGELOG.mdis the user-facing changelog shown inside the Home Assistant add-on UI. It benefits from a shorter, curated log, so release-please does not touch it. Update it in the release PR when user-facing add-on changes ship.docs/changelog.mdis a one-line VitePress include (<!--@include: ../CHANGELOG.md-->), so it updates automatically as soon asCHANGELOG.mddoes. Do not replace that include with hand-written content.
By default the workflow authenticates with GITHUB_TOKEN. GitHub intentionally suppresses downstream workflow triggers for events raised by that token, which means the release PR does not trigger ci.yml and the resulting GitHub release does not trigger docker.yml.
To get those to chain automatically, create a classic Personal Access Token with repo + workflow scopes and save it as a repository secret named RELEASE_PLEASE_TOKEN. The workflow already prefers it over GITHUB_TOKEN.
Until the PAT is configured, the fallback is:
- Re-run
ci.ymlon the release PR by clicking "Close pull request" then "Reopen pull request" (or pushing an empty commit to the release branch). - Trigger
docker.ymlmanually from the Actions tab (workflow_dispatch, input the new tag).
Found a bug or have a feature request? Open an issue at github.com/KristianP26/ble-scale-sync/issues.