From ae8e2d77aac5f56eb98a101c012f52a0ae9eb344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 13:23:01 +0200 Subject: [PATCH] feat(withings): ingest meastype 12 (legacy Thermo temperature) into BODY_TEMPERATURE First-gen Withings Thermo (WBT01) reports body temperature under meastype 12. The current-gen Thermo ships meastype 71 (next commit). Both decode through the same `value * 10^unit` path the client already applies, and target the `BODY_TEMPERATURE` enum slot we added in v1.4.23 for the Apple Health passthrough. Adds `src/lib/withings/__tests__/client.test.ts` with a synthetic `getmeas` payload covering the exponent decode, unknown-meastype skip, and two-decimal rounding. Re-exports MEASURE_TYPE_MAP so the test can assert the mapping directly without importing internals. --- src/lib/withings/__tests__/client.test.ts | 89 +++++++++++++++++++++++ src/lib/withings/client.ts | 8 +- 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/lib/withings/__tests__/client.test.ts diff --git a/src/lib/withings/__tests__/client.test.ts b/src/lib/withings/__tests__/client.test.ts new file mode 100644 index 000000000..1b1a4060a --- /dev/null +++ b/src/lib/withings/__tests__/client.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MEASURE_TYPE_MAP, fetchMeasurements } from "../client"; + +/** + * Withings client — meastype mapping + payload-parsing unit tests. + * + * Each new meastype gets a dedicated case that drives a synthetic + * `measure-getmeas` payload through `fetchMeasurements()` and asserts the + * mapped row shape. Edge cases (unknown type, missing exponent) live at + * the end so a new mapping commit doesn't bloat the diff. + */ + +interface FakeMeasure { + type: number; + value: number; + unit: number; +} + +function fakeGetmeasPayload(measures: FakeMeasure[], date = 1730000000) { + return { + status: 0, + body: { + updatetime: "2024-10-27T00:00:00Z", + timezone: "Europe/Berlin", + measuregrps: [ + { + grpid: 1, + attrib: 0, + date, + created: date, + modified: date, + measures, + }, + ], + more: false, + offset: 0, + }, + }; +} + +function installFetchMock(payload: unknown) { + const fetchMock = vi.fn(async () => ({ + status: 200, + json: async () => payload, + })); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("MEASURE_TYPE_MAP", () => { + it("maps Withings meastype 12 (legacy Thermo) → BODY_TEMPERATURE", () => { + expect(MEASURE_TYPE_MAP[12]).toEqual({ type: "BODY_TEMPERATURE" }); + }); +}); + +describe("fetchMeasurements — temperature (meastype 12)", () => { + it("decodes the value × 10^unit exponent and emits a BODY_TEMPERATURE row", async () => { + // 36.8 °C encoded as Withings exponent shape: value=368, unit=-1. + installFetchMock(fakeGetmeasPayload([{ type: 12, value: 368, unit: -1 }])); + const out = await fetchMeasurements("token"); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ type: "BODY_TEMPERATURE", value: 36.8 }); + }); + + it("skips an unknown meastype without throwing", async () => { + installFetchMock( + fakeGetmeasPayload([ + { type: 12, value: 368, unit: -1 }, + // 9999 is not in MEASURE_TYPE_MAP → must be ignored. + { type: 9999, value: 1, unit: 0 }, + ]), + ); + const out = await fetchMeasurements("token"); + expect(out).toHaveLength(1); + expect(out[0].type).toBe("BODY_TEMPERATURE"); + }); + + it("rounds to two decimals (Withings sometimes ships fractional exponents)", async () => { + // 36.825 → stored as 36.83 once `parseFloat(value.toFixed(2))` runs. + installFetchMock(fakeGetmeasPayload([{ type: 12, value: 36825, unit: -3 }])); + const out = await fetchMeasurements("token"); + expect(out[0].value).toBe(36.83); + }); +}); diff --git a/src/lib/withings/client.ts b/src/lib/withings/client.ts index eefd7d62c..be39e63b2 100644 --- a/src/lib/withings/client.ts +++ b/src/lib/withings/client.ts @@ -119,14 +119,18 @@ export async function refreshAccessToken( return json.body as WithingsTokenResponse; } -// Withings measure type mapping +// Withings measure type mapping. The single source of truth is +// `src/lib/withings/mapping.md` — keep both in sync when adding entries. // https://developer.withings.com/api-reference#tag/measure/operation/measure-getmeas -const MEASURE_TYPE_MAP: Record = { +export const MEASURE_TYPE_MAP: Record = { 1: { type: "WEIGHT" }, // Weight (kg) 9: { type: "BLOOD_PRESSURE_DIA" }, // Diastolic BP 10: { type: "BLOOD_PRESSURE_SYS" }, // Systolic BP 11: { type: "PULSE" }, // Heart rate 6: { type: "BODY_FAT" }, // Body fat % + // v1.4.25 — first-gen Thermo (WBT01) reports temperature as meastype 12; + // current-gen ships meastype 71 instead (see below). Both canonical °C. + 12: { type: "BODY_TEMPERATURE" }, 77: { type: "TOTAL_BODY_WATER" }, // Hydration / water mass (kg) 88: { type: "BONE_MASS" }, // Bone mass (kg) 54: { type: "OXYGEN_SATURATION" }, // SpO2 (% — only ScanWatch / pulse-ox products)