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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:23:01 +02:00
1 parent cbcc0598b2
commit ae8e2d77aa
2 files changed
+95 -2

No files matched your search

+89
View File
@@ -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);
});
});
+6 -2
View File
@@ -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<number, { type: string; factor?: number }> = {
export const MEASURE_TYPE_MAP: Record<number, { type: string; factor?: number }> = {
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)