that was requested few times in the past but a direct integration is not available for Withings.
For what I know, you have to use a (paid, but cheap) app to sync withings to intervals data
that was requested few times in the past but a direct integration is not available for Withings.
For what I know, you have to use a (paid, but cheap) app to sync withings to intervals data
I double-checked, and it looks like Withings doesnβt have native integration with Intervals.icu just yet. As you mentioned, using a third-party app like HealthFit or SyncMyTracks is currently the workaround to get Withings data into Intervals.icu.
Great to know Patrick added Muscle Mass support; itβs a much-needed feature. Smart Scale Sync just keeps getting better!
Hi Guys, FYI - I have been using scales bought from Amazon called FitIndex Smart Body Scale. They integrate with Fitbit and Apple Health. Initially I replaced the Fitbit scales as they broke, and continue to get the data into Fitbit. I was using Fitbit > MyFitnessPal > Garmin > Intervals.icu
Now I use Breakaway app to Sync the wellness data back to Intervals.icu and also Garmin. It works great, much better than my old Fitbit scales which was unreliable. I was surprised they work so good as they are much cheaper and can often be found on sale
FitIndex Scale > Apple Health > Breakaway > Intervals.icu & Garmin
Hope this helps
I built a Node.js script (with Claudeβs help of course) that listens to a BLE xiaomiβs my body composition scale 2, reads the metrics (weight and bioimpedance), makes calcs and pushes them to Intervals.icu automatically. Since weβre two athletes sharing the same scale at home, it identifies whoβs weighing based on configurable weight ranges.
It used to be an html using web-bluetooth.
As long as the scale is within Bluetooth range of the machine running the script, itβll do the job hands-free β just step on and it syncs.
The only caveat: both athletes need to configure the same custom wellness fields in Intervals.icu, since the script pushes bone mass, muscle mass, BMI, visceral fat, metabolic age, ideal weight, body water, protein, and BMR. I couldnβt find any API to create these fields automaticallyβ¦
Future idea: send a confirmation message to Telegram after each sync ![]()
Config file
Create config.json in the same directory as the script:
{
"profiles": [
{
"name": "Alice",
"athleteId": "YOUR_ATHLETE_ID",
"apiKey": "YOUR_API_KEY",
"weightMin": 50,
"weightMax": 65,
"height": 1.65,
"age": 32,
"sex": "female"
},
{
"name": "Bob",
"athleteId": "YOUR_ATHLETE_ID",
"apiKey": "YOUR_API_KEY",
"weightMin": 70,
"weightMax": 90,
"height": 1.80,
"age": 35,
"sex": "male"
}
]
}
#!/usr/bin/env node
/**
* Scale Monitor β headless BLE listener for Xiaomi body composition scale.
* Detects who's weighing by weight range, computes body metrics,
* and sends to Intervals.icu automatically.
*
* Usage: node scripts/scale-monitor/index.js
*/
const noble = require('@abandonware/noble')
const fs = require('fs')
const path = require('path')
const CONFIG_PATH = path.join(__dirname, 'config.json')
const BODY_COMP_SERVICE = '181b'
const BODY_COMP_CHAR = '2a9c'
const INTERVALS_TIMEOUT_MS = 10000
// ββ Config ββ
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) {
console.error('β config.json not found')
console.error(' Copy config.example.json β config.json and fill in your details')
process.exit(1)
}
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
if (!config.profiles?.length) {
console.error('β config.json has no profiles')
process.exit(1)
}
return config
}
// ββ Profile matching ββ
function matchProfile(weight, profiles) {
return profiles.find(p => weight >= p.weightMin && weight <= p.weightMax)
}
// ββ Body composition ββ
function computeMetrics(weight, impedance, profile) {
const heightM = profile.height
const heightCm = heightM * 100
const age = profile.age
const isMale = profile.sex === 'male'
const biaIndex = (heightCm * heightCm) / impedance
const fatFreeMass = isMale
? -10.678 + 0.652 * biaIndex + 0.262 * weight + 0.015 * impedance
: -9.529 + 0.696 * biaIndex + 0.168 * weight + 0.016 * impedance
const totalBodyWaterL = isMale
? 0.396 * biaIndex + 0.143 * weight + 8.399
: 0.382 * biaIndex + 0.105 * weight + 8.315
const boneKg = Math.max(0.5, weight * 0.030)
const fatPct = Math.max(3, Math.min(50, (weight - fatFreeMass) / weight * 100))
const waterPct = Math.max(30, Math.min(75, totalBodyWaterL / weight * 100))
const muscleKg = Math.max(10, Math.min(80, fatFreeMass * 0.82))
const musclePct = Math.max(20, Math.min(60, muscleKg / weight * 100))
const bonePct = Math.max(1, Math.min(8, boneKg / weight * 100))
const proteinPct = Math.max(5, Math.min(25, (fatFreeMass - totalBodyWaterL - boneKg) / weight * 100))
const bmi = weight / (heightM * heightM)
const bmr = isMale
? 10 * weight + 6.25 * heightCm - 5 * age + 5
: 10 * weight + 6.25 * heightCm - 5 * age - 161
const idealWeight = 21.7 * heightM * heightM
const visceralBase = isMale
? fatPct * 0.68 + age * 0.15 - 7.5
: fatPct * 0.58 + age * 0.13 - 8.0
const visceralFat = Math.max(1, Math.min(30, Math.round(visceralBase)))
const bmrRef = isMale
? 10 * idealWeight + 6.25 * heightCm - 5 * age + 5
: 10 * idealWeight + 6.25 * heightCm - 5 * age - 161
const metabolicAge = Math.max(15, Math.min(80, Math.round(age + (bmrRef - bmr) / 15)))
const r1 = n => Math.round(n * 10) / 10
return {
weight: Math.round(weight * 100) / 100,
bodyFat: r1(fatPct),
muscleMass: r1(musclePct),
bodyWater: r1(waterPct),
boneMass: r1(bonePct),
visceralFat,
protein: r1(proteinPct),
bmi: r1(bmi),
bmr: Math.round(bmr),
metabolicAge,
idealWeight: r1(idealWeight),
}
}
// ββ Intervals.icu ββ
async function sendToIntervals(metrics, profile) {
const today = new Date().toISOString().split('T')[0]
const payload = [{
id: today,
weight: metrics.weight,
bodyFat: metrics.bodyFat,
MuscleMass: metrics.muscleMass,
BodyWater: metrics.bodyWater,
BoneMass: metrics.boneMass,
VisceralFat: metrics.visceralFat,
BMI: metrics.bmi,
BasalMetabolicRate: metrics.bmr,
MetabolicAge: metrics.metabolicAge,
Protein: metrics.protein,
IdealWeight: metrics.idealWeight,
}]
const url = `https://intervals.icu/api/v1/athlete/${profile.athleteId}/wellness-bulk`
const auth = 'Basic ' + Buffer.from('API_KEY:' + profile.apiKey).toString('base64')
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), INTERVALS_TIMEOUT_MS)
try {
const res = await fetch(url, {
method: 'PUT',
headers: { 'Authorization': auth, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
})
clearTimeout(timeout)
if (res.ok) {
log(`β
Sent to Intervals for ${profile.name}`)
} else {
const body = await res.text()
log(`β οΈ Intervals responded ${res.status}: ${body.slice(0, 200)}`)
}
} catch (err) {
clearTimeout(timeout)
log(`β Error sending to Intervals: ${err.message}`)
}
}
// ββ Logging ββ
function log(msg) {
const ts = new Date().toLocaleString('en-US')
console.log(`[${ts}] ${msg}`)
}
// ββ BLE data parsing ββ
let lastWeight = null
let measurementDone = false
function handleData(data, profiles) {
if (data.length < 13) return
const flags = data.readUInt16LE(0)
const impedanceRaw = data.readUInt16LE(9)
const weightRaw = data.readUInt16LE(11)
const weight = weightRaw * 0.005
const isStabilized = !!(flags & 0x0020)
// Reset after step-off (weight drops below 20)
if (measurementDone) {
if (weight >= 20 && isStabilized) {
measurementDone = false
log('π New measurement detected')
} else {
return
}
}
const IMPEDANCE_MIN = 100
const IMPEDANCE_MAX = 3000
const hasValidImpedance = impedanceRaw >= IMPEDANCE_MIN && impedanceRaw <= IMPEDANCE_MAX
if (hasValidImpedance) {
const finalWeight = lastWeight || (weight >= 20 ? weight : null)
if (!finalWeight) return
const profile = matchProfile(finalWeight, profiles)
if (!profile) {
log(`β οΈ Weight ${finalWeight.toFixed(1)} kg doesn't match any profile`)
lastWeight = null
measurementDone = true
return
}
const metrics = computeMetrics(finalWeight, impedanceRaw, profile)
log(`π ${profile.name}: ${metrics.weight} kg | fat ${metrics.bodyFat}% | muscle ${metrics.muscleMass}%`)
lastWeight = null
measurementDone = true
sendToIntervals(metrics, profile)
return
}
// Impedance sentinel β skip
if (impedanceRaw > IMPEDANCE_MAX) return
if (weight >= 20 && weight <= 300) {
if (isStabilized) {
lastWeight = weight
log(`β³ Weight stabilized: ${weight.toFixed(2)} kg β waiting for impedance...`)
}
}
}
// ββ BLE scanning & connection ββ
function startScanning(profiles) {
log('π Scanning for scale (MIBFS / Xiaomi)...')
noble.on('stateChange', (state) => {
if (state === 'poweredOn') {
// Scan without service filter β many Xiaomi scales don't advertise 0x181b
noble.startScanning([], false)
} else {
log(`β οΈ Bluetooth state: ${state}`)
noble.stopScanning()
}
})
noble.on('discover', (peripheral) => {
const name = (peripheral.advertisement.localName || '').toUpperCase()
// Match known Xiaomi scale names: MIBFS, MIBCS, XMTZC, MI SCALE, etc.
if (!name.match(/^(MIBFS|MIBCS|XMTZC|MI\s*SCALE)/)) return
log(`π‘ Found: ${peripheral.advertisement.localName} (${peripheral.id})`)
noble.stopScanning()
connectToScale(peripheral, profiles)
})
}
function connectToScale(peripheral, profiles) {
peripheral.connect((err) => {
if (err) {
log(`β Connection error: ${err.message}`)
setTimeout(() => {
log('π Retrying...')
noble.startScanning([BODY_COMP_SERVICE], false)
}, 5000)
return
}
log(`β
Connected to ${peripheral.advertisement.localName || peripheral.id}`)
peripheral.once('disconnect', () => {
log('β οΈ Disconnected β rescanning...')
lastWeight = null
measurementDone = false
setTimeout(() => noble.startScanning([BODY_COMP_SERVICE], false), 2000)
})
peripheral.discoverSomeServicesAndCharacteristics(
[BODY_COMP_SERVICE],
[BODY_COMP_CHAR],
(err, services, characteristics) => {
if (err) {
log(`β Error discovering services: ${err.message}`)
peripheral.disconnect()
return
}
const char = characteristics[0]
if (!char) {
log('β Body composition characteristic not found')
peripheral.disconnect()
return
}
char.subscribe((err) => {
if (err) {
log(`β Subscription error: ${err.message}`)
peripheral.disconnect()
return
}
log('π‘ Listening for measurements... step on the scale!')
})
char.on('data', (data) => handleData(data, profiles))
}
)
})
}
// ββ Main ββ
const config = loadConfig()
log('π Scale Monitor started')
log(` ${config.profiles.length} profiles loaded:`)
config.profiles.forEach(p => {
log(` β’ ${p.name}: ${p.weightMin}β${p.weightMax} kg β ${p.athleteId}`)
})
startScanning(config.profiles)
// Graceful shutdown
process.on('SIGINT', () => {
log('π Shutting down...')
noble.stopScanning()
process.exit(0)
})
Adjust weightMin/weightMax so the ranges donβt overlap. Height is in meters. No repo, just personal stuff. Sorry guys!