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:
Marc-André Bombeck committed 2026-11-04 10:14:10 +01:00
1 parent 67909f2d9d
commit e2ba86b12f
119 files changed
+5798 -400

No files matched your search

+18
View File
@@ -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",