mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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
@@ -184,6 +184,7 @@ export async function loadConnectionFacts(
|
||||
stravaAccessTokenEncrypted: true,
|
||||
nightscoutUrlEncrypted: true,
|
||||
dawarichUrlEncrypted: true,
|
||||
garminApiKeyEncrypted: true,
|
||||
healthKitLastSyncedAt: true,
|
||||
},
|
||||
}),
|
||||
@@ -245,6 +246,7 @@ export async function loadConnectionFacts(
|
||||
strava: !!dbUser?.stravaAccessTokenEncrypted,
|
||||
nightscout: !!dbUser?.nightscoutUrlEncrypted,
|
||||
dawarich: !!dbUser?.dawarichUrlEncrypted,
|
||||
garmin: !!dbUser?.garminApiKeyEncrypted,
|
||||
// Apple Health has nothing to connect; it is "connected" once the phone
|
||||
// has delivered anything (a live batch, or rows from the export import).
|
||||
"apple-health": false,
|
||||
|
||||
@@ -44,6 +44,7 @@ export const CONNECTION_KEYS: readonly ConnectionKey[] = [
|
||||
"strava",
|
||||
"nightscout",
|
||||
"dawarich",
|
||||
"garmin",
|
||||
"apple-health",
|
||||
];
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export const INTEGRATION_MEASUREMENT_SOURCE: Partial<
|
||||
polar: "POLAR",
|
||||
oura: "OURA",
|
||||
"google-health": "GOOGLE_HEALTH",
|
||||
garmin: "GARMIN",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -72,6 +73,7 @@ export const INTEGRATION_WORKOUT_SOURCE: Partial<
|
||||
whoop: "WHOOP",
|
||||
fitbit: "FITBIT",
|
||||
"google-health": "GOOGLE_HEALTH",
|
||||
garmin: "GARMIN",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function getSourcesInUse(
|
||||
polarAccessTokenEncrypted: true,
|
||||
ouraAccessTokenEncrypted: true,
|
||||
stravaAccessTokenEncrypted: true,
|
||||
garminApiKeyEncrypted: true,
|
||||
},
|
||||
}),
|
||||
prisma.withingsConnection.findUnique({
|
||||
@@ -79,6 +80,7 @@ export async function getSourcesInUse(
|
||||
if (user?.polarAccessTokenEncrypted) inUse.add("POLAR");
|
||||
if (user?.ouraAccessTokenEncrypted) inUse.add("OURA");
|
||||
if (user?.stravaAccessTokenEncrypted) inUse.add("STRAVA");
|
||||
if (user?.garminApiKeyEncrypted) inUse.add("GARMIN");
|
||||
|
||||
return RANKABLE_SOURCES.filter((s) => inUse.has(s));
|
||||
}
|
||||
@@ -63,7 +63,9 @@ export type IntegrationKey =
|
||||
// v1.28.x — Strava OAuth workout source.
|
||||
| "strava"
|
||||
// v1.43 — Dawarich movement context (daily values, nightly).
|
||||
| "dawarich";
|
||||
| "dawarich"
|
||||
// Garmin Personal API (a pasted key, hourly poll).
|
||||
| "garmin";
|
||||
|
||||
/**
|
||||
* Failure kinds carried into `recordSyncFailure`.
|
||||
@@ -1281,6 +1283,7 @@ const INTEGRATION_LABELS: Record<IntegrationKey, string> = {
|
||||
"google-health": "Google Health",
|
||||
strava: "Strava",
|
||||
dawarich: "Dawarich",
|
||||
garmin: "Garmin",
|
||||
};
|
||||
|
||||
const FAILURE_KIND_COPY: Record<
|
||||
|
||||
@@ -126,6 +126,7 @@ export const INTEGRATION_CADENCE: Record<IntegrationKey, SyncCadence> = {
|
||||
"google-health": POLLED_CADENCE,
|
||||
strava: POLLED_CADENCE,
|
||||
dawarich: NIGHTLY_CADENCE,
|
||||
garmin: POLLED_CADENCE,
|
||||
};
|
||||
|
||||
export interface SyncVerdictInput {
|
||||
|
||||
@@ -62,7 +62,7 @@ export const INCIDENT_AFTER_MS = DAY_MS;
|
||||
*
|
||||
* - 24 h for wearables and CGMs whose cloud receives data on its own
|
||||
* (Fitbit, Google Health, Polar, Nightscout).
|
||||
* - 48 h for Oura and WHOOP. Their rings and straps upload only when the
|
||||
* - 48 h for Oura, WHOOP and Garmin. Their rings and straps upload only when the
|
||||
* phone app is opened, so a day without opening it is ordinary and must
|
||||
* not page anyone.
|
||||
* - 48 h for Apple Health. iOS defers background delivery when the phone is
|
||||
@@ -86,6 +86,9 @@ export const SOURCE_PROFILES: Record<ConnectionKey, SourceProfile> = {
|
||||
polar: { kind: "continuous", silenceWindowMs: DAY_MS },
|
||||
nightscout: { kind: "continuous", silenceWindowMs: DAY_MS },
|
||||
dawarich: { kind: "on_demand", silenceWindowMs: DAY_MS },
|
||||
// A Garmin watch uploads through the phone app like the rings and straps,
|
||||
// so a day without opening it is ordinary.
|
||||
garmin: { kind: "continuous", silenceWindowMs: 2 * DAY_MS },
|
||||
"apple-health": { kind: "continuous", silenceWindowMs: 2 * DAY_MS },
|
||||
};
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ const LABELS: Record<ConnectionKey, string> = {
|
||||
strava: "Strava",
|
||||
nightscout: "Nightscout",
|
||||
dawarich: "Dawarich",
|
||||
garmin: "Garmin",
|
||||
"apple-health": "Apple Health",
|
||||
};
|
||||
|
||||
@@ -145,6 +146,7 @@ async function listWatchedUserIds(): Promise<string[]> {
|
||||
{ stravaAccessTokenEncrypted: { not: null } },
|
||||
{ nightscoutUrlEncrypted: { not: null } },
|
||||
{ dawarichUrlEncrypted: { not: null } },
|
||||
{ garminApiKeyEncrypted: { not: null } },
|
||||
{ healthKitLastSyncedAt: { not: null } },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user