From e504faa7b64af6fc2700d1b5a93cf3ea147a2bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 18:20:14 +0200 Subject: [PATCH] feat(withings): expand webhook subscriptions to activity + sleep v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WITHINGS_NOTIFY_APPLIS grows from [1, 2, 4] to [1, 2, 4, 16, 44]: appli=16 (Activity) and appli=44 (Sleep v2) join the webhook-primary set so the new sync routines fire in seconds rather than waiting for the hourly cron fallback. setupWebhook iterates the new list unchanged — Withings has no batch subscribe endpoint, so every appli is its own POST. The two new categories require the user.activity OAuth scope from W5d; legacy connections that never reconnected return 503/293 on those subscribes, which setupWebhook already logs as a per-appli warning while keeping the remaining subscriptions live. --- src/lib/withings/__tests__/client.test.ts | 64 ++++++++++++++++++++++- src/lib/withings/sync.ts | 16 ++++-- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/lib/withings/__tests__/client.test.ts b/src/lib/withings/__tests__/client.test.ts index a200f52d3..5e3723b2f 100644 --- a/src/lib/withings/__tests__/client.test.ts +++ b/src/lib/withings/__tests__/client.test.ts @@ -5,6 +5,7 @@ import { fetchMeasurements, hasActivityScope, parseWithingsScope, + subscribeWebhook, } from "../client"; import { WITHINGS_NOTIFY_APPLIS } from "../sync"; @@ -138,8 +139,11 @@ describe("WITHINGS_OAUTH_SCOPE + scope helpers", () => { }); describe("WITHINGS_NOTIFY_APPLIS", () => { - it("subscribes to weight + temperature + pressure categories", () => { - expect(WITHINGS_NOTIFY_APPLIS).toEqual([1, 2, 4]); + it("subscribes to weight + temperature + pressure + activity + sleep categories", () => { + // v1.4.25 W17b/c — activity (16) + sleep v2 (44) join the + // webhook-primary set so the new sync routines fire in seconds + // rather than waiting for the hourly cron fallback. + expect(WITHINGS_NOTIFY_APPLIS).toEqual([1, 2, 4, 16, 44]); }); it("contains every appli for the meastypes we ingest", () => { @@ -150,12 +154,68 @@ describe("WITHINGS_NOTIFY_APPLIS", () => { // - 9, 10, 11, 35, 54 → appli=4 (BP + pulse + SpO2) // - 123 → appli=1 (VO2 max is part of the weight category in // Withings' bucketing; verified against the developer guide). + // - steps + distance + active energy + floors → appli=16 (Activity) + // - sleep stage segments → appli=44 (Sleep v2) const ingested = Object.keys(MEASURE_TYPE_MAP).map(Number).sort(); expect(ingested).toContain(12); expect(ingested).toContain(71); expect(ingested).toContain(35); expect(ingested).toContain(123); }); + + it("includes 16 (activity) and 44 (sleep v2) — the W17b/c webhook additions", () => { + // Explicit guard so a future refactor that re-sorts the array + // doesn't silently drop the activity / sleep webhook trigger. + expect(WITHINGS_NOTIFY_APPLIS).toContain(16); + expect(WITHINGS_NOTIFY_APPLIS).toContain(44); + }); +}); + +describe("subscribeWebhook — appli payload", () => { + it("POSTs `appli=16` to Withings notify when called with the activity category", async () => { + const fetchMock = vi.fn(async () => ({ + status: 200, + json: async () => ({ status: 0 }), + })); + vi.stubGlobal("fetch", fetchMock); + + await subscribeWebhook( + "token", + "https://healthlog.example.com/api/withings/webhook/secret", + 16, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + const body = String(init.body); + expect(body).toContain("action=subscribe"); + expect(body).toContain("appli=16"); + }); + + it("POSTs `appli=44` to Withings notify when called with the sleep v2 category", async () => { + const fetchMock = vi.fn(async () => ({ + status: 200, + json: async () => ({ status: 0 }), + })); + vi.stubGlobal("fetch", fetchMock); + + await subscribeWebhook( + "token", + "https://healthlog.example.com/api/withings/webhook/secret", + 44, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + const body = String(init.body); + expect(body).toContain("appli=44"); + }); }); describe("fetchMeasurements — VO2 max (meastype 123)", () => { diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 4da156e6a..9cf117105 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -294,15 +294,21 @@ export function extractWithingsStatus(message: string): string | undefined { * - 1 — weight + body composition (meastypes 1, 5, 6, 8, 88) * - 2 — temperature (meastypes 12, 71, 73) * - 4 — pressure family (BP dia 9, BP sys 10, pulse 11, SpO2 54) + * - 16 — activity (steps, distance, active energy, floors climbed); + * v1.4.25 W17b webhook-primary trigger for the new + * `syncUserActivity` routine. + * - 44 — sleep v2 (per-stage segments + nightly summary); v1.4.25 W17c + * webhook-primary trigger for the new `syncUserSleep` routine. * * Without 2 and 4, BP and temperature readings flow only through the * hourly poll fallback. Adding them removes up to an hour of latency - * on a freshly-taken BP reading without changing the OAuth scope. - * - * Sleep (appli=44) and activity (appli=16) ship alongside the - * corresponding sync routines in v1.4.26. + * on a freshly-taken BP reading without changing the OAuth scope. The + * activity + sleep categories require the `user.activity` scope from + * W5d; legacy connections that never reconnected sit on `user.metrics` + * only and the subscribe call returns 503/293 — `setupWebhook` logs + * the failure and keeps the remaining appli subscriptions. */ -export const WITHINGS_NOTIFY_APPLIS = [1, 2, 4] as const; +export const WITHINGS_NOTIFY_APPLIS = [1, 2, 4, 16, 44] as const; /** * Subscribe to every Withings notify category HealthLog ingests. Each