mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(garmin): sync Garmin Connect through the Personal API
Garmin Connect now offers a Personal API: a person creates a key for their own account and any client can buy short-lived tokens with it. HealthLog takes that key in a new Garmin card, checks it with Garmin before storing it sealed on the user row, and polls every hour. - Transport: key exchange with a per-process token cache keyed on the key, one re-exchange on a refused token and then reauth_required, the 429 Retry-After carried to the caller, the China host tried when the global one does not know the key, answers read with a byte cap, all egress through safeFetch to the two fixed hosts. - Sync: daily summaries, sleep summaries, HRV, weigh-ins and the activity search, chunked to each resource's documented range, read one after another and stopped at the first rate limit; today's menstrual cycle summary adds logged period days the person has not logged. - Backfill: a resumable walk through the shared admission lane that learns where the history starts from the daily summaries and the activities, waits out Garmin's rate limit and stamps its marker. - Types: GARMIN source; BODY_BATTERY and STRESS_LEVEL for Garmin's own day scores, kept apart from the computed STRESS_SCORE; TRAINING_READINESS and ACUTE_TRAINING_LOAD for figures a bridge pushes through the ingest token. All four chart on the recovery page and sit on the ladders, units, ranges, labels and report groups like the device-native scores. - Key expiry: learned from a token capped short of a day, else estimated from the validity picked at connect; the card warns a week ahead and asks for a new key once Garmin refuses the old one. - Ledger, health, freshness, watchdog, backup classes, wipe plan, encrypted-column registry, rotation script, OpenAPI and all ten locales carry the new integration. The old "no Garmin connector" note is gone.
This commit is contained in:
1 parent
67909f2d9d
commit
e2ba86b12f
119 files changed
+5798
-400
No files matched your search
@@ -103,7 +103,7 @@ describe("sourcePrioritySchema", () => {
|
||||
|
||||
it("rejects an unknown source string", () => {
|
||||
const result = sourcePrioritySchema.safeParse({
|
||||
weight: ["GARMIN", "WITHINGS"],
|
||||
weight: ["SAMSUNG_HEALTH", "WITHINGS"],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
@@ -578,7 +578,12 @@ describe("DEFAULT_SOURCE_PRIORITY — the sources that had no place (v1.43.11)",
|
||||
});
|
||||
|
||||
it("leaves the order of the sources the ladders already named alone", () => {
|
||||
const added = new Set<string>([...HAND_ENTERED, "HEALTH_CONNECT"]);
|
||||
const added = new Set<string>([
|
||||
...HAND_ENTERED,
|
||||
"HEALTH_CONNECT",
|
||||
// Garmin joined the ladders later; without it the rest kept its order.
|
||||
"GARMIN",
|
||||
]);
|
||||
const without = (key: keyof typeof DEFAULT_SOURCE_PRIORITY) =>
|
||||
DEFAULT_SOURCE_PRIORITY[key].filter((s) => !added.has(s));
|
||||
expect(without("weight")).toEqual([
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { GARMIN_KEY_VALIDITY_DAYS } from "@/lib/garmin/key-expiry";
|
||||
|
||||
/**
|
||||
* `POST /api/garmin/connect`. The key is what Garmin Connect shows once when
|
||||
* it is created (Settings, Personal API). It is trimmed before use: a copy
|
||||
* button's trailing newline would otherwise reach Garmin and answer as a
|
||||
* refused key. `validityDays` is the validity picked at creation; Garmin
|
||||
* does not report it, so it is the only way to warn ahead of the end until
|
||||
* the key's last day gives it away.
|
||||
*/
|
||||
export const garminConnectSchema = z.object({
|
||||
apiKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(16, "The Garmin key is too short")
|
||||
.max(1024, "The Garmin key is too long")
|
||||
.regex(/^\S+$/, "The Garmin key cannot contain spaces"),
|
||||
validityDays: z
|
||||
.union(GARMIN_KEY_VALIDITY_DAYS.map((d) => z.literal(d)))
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type GarminConnectInput = z.infer<typeof garminConnectSchema>;
|
||||
@@ -157,6 +157,18 @@ export const measurementTypeEnum = z.enum([
|
||||
"INDOOR_CO2",
|
||||
"INDOOR_TEMPERATURE",
|
||||
"INDOOR_HUMIDITY",
|
||||
// ── Garmin Personal API (#1333) ──
|
||||
// BODY_BATTERY and STRESS_LEVEL ingest server-side as `source = GARMIN`.
|
||||
// STRESS_LEVEL is Garmin's own all-day stress average and is kept apart
|
||||
// from the COMPUTED STRESS_SCORE proxy: the score readers collapse only the
|
||||
// recovery set across sources, so a second STRESS_SCORE producer would be
|
||||
// averaged with the proxy instead of ranked above it.
|
||||
"BODY_BATTERY",
|
||||
"STRESS_LEVEL",
|
||||
// The Personal API does not carry these two; a home-automation bridge pushes
|
||||
// them through the ingest token (`measurements:write`, source EXTERNAL).
|
||||
"TRAINING_READINESS",
|
||||
"ACUTE_TRAINING_LOAD",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -261,12 +261,20 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
// device-native wearables and above MANUAL so an Apple-Watch + Strava twin of
|
||||
// the same run collapses onto the richer device row, while a Strava-only run
|
||||
// still survives.
|
||||
// Garmin (Personal API) is a worn watch in the same class as the
|
||||
// Fitbit / Google Health wearables and ranks right after them on every
|
||||
// ladder it writes to, above the rings and straps that follow. On weight
|
||||
// and body fat it sits with the scale-fed sources, since a Garmin Index
|
||||
// scale reports there; below WITHINGS and the HealthKit relay. It writes
|
||||
// no SDNN, so the `hrv` ladder does not name it; its overnight HRV is
|
||||
// RMSSD and ranks on `hrvRmssd`.
|
||||
steps: [
|
||||
"APPLE_HEALTH",
|
||||
"HEALTH_CONNECT",
|
||||
"WITHINGS",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
"GARMIN",
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"STRAVA",
|
||||
@@ -281,6 +289,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"WITHINGS",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
"GARMIN",
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"MANUAL",
|
||||
@@ -294,6 +303,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"WITHINGS",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
"GARMIN",
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"MANUAL",
|
||||
@@ -306,6 +316,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"WITHINGS",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
"GARMIN",
|
||||
"MANUAL",
|
||||
"EXTERNAL",
|
||||
"MCP",
|
||||
@@ -323,6 +334,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"WHOOP",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
"GARMIN",
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"APPLE_HEALTH",
|
||||
@@ -341,6 +353,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"WHOOP",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
"GARMIN",
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"APPLE_HEALTH",
|
||||
@@ -352,6 +365,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"WITHINGS",
|
||||
"APPLE_HEALTH",
|
||||
"HEALTH_CONNECT",
|
||||
"GARMIN",
|
||||
"MANUAL",
|
||||
"EXTERNAL",
|
||||
"MCP",
|
||||
@@ -389,6 +403,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"WITHINGS",
|
||||
"APPLE_HEALTH",
|
||||
"HEALTH_CONNECT",
|
||||
"GARMIN",
|
||||
"MANUAL",
|
||||
"EXTERNAL",
|
||||
"MCP",
|
||||
@@ -415,6 +430,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"WHOOP",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
"GARMIN",
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"APPLE_HEALTH",
|
||||
@@ -446,6 +462,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"WHOOP",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
"GARMIN",
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"APPLE_HEALTH",
|
||||
@@ -475,6 +492,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"WHOOP",
|
||||
"FITBIT",
|
||||
"GOOGLE_HEALTH",
|
||||
"GARMIN",
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"APPLE_HEALTH",
|
||||
|
||||
Reference in new issue
Block a user