← 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 };