← back to AbramsOS

lib/withings-client.js

75 lines

// withings-client.js — Withings Health API (OAuth2) client for auto-syncing device
// readings (weight, blood pressure, heart rate, SpO2) into the Vitals module.
//
// One-set-of-creds-from-working: register an app at developer.withings.com → set
// WITHINGS_CLIENT_ID + WITHINGS_CLIENT_SECRET (+ optional WITHINGS_REDIRECT_URI). No creds
// → graceful "not configured". Withings ROTATES the refresh token on every refresh, so the
// caller must persist the new refresh_token each sync. Never fabricates a reading.

const CLIENT_ID = () => process.env.WITHINGS_CLIENT_ID;
const CLIENT_SECRET = () => process.env.WITHINGS_CLIENT_SECRET;
const REDIRECT = () => process.env.WITHINGS_REDIRECT_URI || 'http://localhost:9774/api/withings/callback';

const AUTH_URL = 'https://account.withings.com/oauth2_user/authorize2';
const TOKEN_URL = 'https://wbsapi.withings.net/v2/oauth2';
const MEAS_URL = 'https://wbsapi.withings.net/measure';

function isConfigured() {
  return Boolean(CLIENT_ID() && CLIENT_SECRET());
}

function authUrl(state) {
  const p = new URLSearchParams({
    response_type: 'code', client_id: CLIENT_ID(), scope: 'user.metrics',
    redirect_uri: REDIRECT(), state: state || 'withings',
  });
  return `${AUTH_URL}?${p.toString()}`;
}

async function tokenRequest(extra) {
  const body = new URLSearchParams({
    action: 'requesttoken', client_id: CLIENT_ID(), client_secret: CLIENT_SECRET(), ...extra,
  });
  const r = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body });
  const j = await r.json();
  if (j.status !== 0) throw new Error(`withings token error ${j.status} ${j.error || ''}`.trim());
  return j.body; // { userid, access_token, refresh_token, expires_in, ... }
}

function exchangeCode(code) {
  return tokenRequest({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT() });
}
function refresh(refresh_token) {
  return tokenRequest({ grant_type: 'refresh_token', refresh_token });
}

// Withings measure type → our metric. BP comes as a group containing 9 + 10 (+ often 11).
const REAL = (m) => m.value * Math.pow(10, m.unit);

async function getMeasures(access_token, lastUpdateEpoch) {
  const body = new URLSearchParams({ action: 'getmeas', access_token, meastypes: '1,9,10,11,54', category: '1' });
  if (lastUpdateEpoch) body.set('lastupdate', String(lastUpdateEpoch));
  const r = await fetch(MEAS_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body });
  const j = await r.json();
  if (j.status !== 0) throw new Error(`withings measure error ${j.status}`);

  const readings = [];
  let newest = lastUpdateEpoch || 0;
  for (const g of (j.body.measuregrps || [])) {
    if (g.date > newest) newest = g.date;
    const when = new Date(g.date * 1000);
    const t = {};
    for (const m of g.measures) t[m.type] = REAL(m);
    const dev = { source: 'device', device_name: 'Withings', measured_at: when };
    if (t[10] != null && t[9] != null) {
      readings.push({ metric: 'blood_pressure', systolic: t[10], diastolic: t[9], unit: 'mmHg', external_id: `withings:bp:${g.grpid}`, ...dev });
    }
    if (t[1] != null) readings.push({ metric: 'weight', value: Math.round(t[1] * 2.20462 * 10) / 10, unit: 'lb', external_id: `withings:wt:${g.grpid}`, ...dev });
    if (t[11] != null) readings.push({ metric: 'heart_rate', value: t[11], unit: 'bpm', external_id: `withings:hr:${g.grpid}`, ...dev });
    if (t[54] != null) readings.push({ metric: 'spo2', value: t[54] <= 1 ? t[54] * 100 : t[54], unit: '%', external_id: `withings:spo2:${g.grpid}`, ...dev });
  }
  return { readings, newest };
}

module.exports = { isConfigured, authUrl, exchangeCode, refresh, getMeasures, REDIRECT };