Body Composition Scales

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.

:raising_hands: 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


Currently I am getting Weight,Lean Body Mass, Body Fat %, BMI into Intervals.icu

FitIndex Scale > Apple Health > Breakaway > Intervals.icu & Garmin

Hope this helps

2 Likes

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 :wink:


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!