feat(withings): expand webhook subscriptions to activity + sleep v2

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 18:20:14 +02:00
1 parent 84af00d3a9
commit e504faa7b6
2 files changed
+73 -7

No files matched your search

+62 -2
View File
@@ -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)", () => {
+11 -5
View File
@@ -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