garmin-intervals-bridge 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. garmin_intervals_bridge-0.2.0/LICENSE +21 -0
  2. garmin_intervals_bridge-0.2.0/PKG-INFO +317 -0
  3. garmin_intervals_bridge-0.2.0/README.md +286 -0
  4. garmin_intervals_bridge-0.2.0/pyproject.toml +54 -0
  5. garmin_intervals_bridge-0.2.0/setup.cfg +4 -0
  6. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/__init__.py +2 -0
  7. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/__main__.py +6 -0
  8. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/charts.py +430 -0
  9. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/cli.py +345 -0
  10. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/config.py +91 -0
  11. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/enrich.py +575 -0
  12. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/fit.py +175 -0
  13. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/garmin.py +237 -0
  14. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/health.py +148 -0
  15. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/intervals.py +216 -0
  16. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/mapping.py +273 -0
  17. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/store.py +286 -0
  18. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/sync.py +375 -0
  19. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/times.py +37 -0
  20. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge.egg-info/PKG-INFO +317 -0
  21. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge.egg-info/SOURCES.txt +35 -0
  22. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge.egg-info/dependency_links.txt +1 -0
  23. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge.egg-info/entry_points.txt +2 -0
  24. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge.egg-info/requires.txt +8 -0
  25. garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge.egg-info/top_level.txt +1 -0
  26. garmin_intervals_bridge-0.2.0/tests/test_charts.py +171 -0
  27. garmin_intervals_bridge-0.2.0/tests/test_enrich.py +210 -0
  28. garmin_intervals_bridge-0.2.0/tests/test_fit.py +107 -0
  29. garmin_intervals_bridge-0.2.0/tests/test_garmin_lazy.py +18 -0
  30. garmin_intervals_bridge-0.2.0/tests/test_health.py +112 -0
  31. garmin_intervals_bridge-0.2.0/tests/test_intervals.py +113 -0
  32. garmin_intervals_bridge-0.2.0/tests/test_mapping.py +137 -0
  33. garmin_intervals_bridge-0.2.0/tests/test_review_fixes.py +374 -0
  34. garmin_intervals_bridge-0.2.0/tests/test_store_locks.py +17 -0
  35. garmin_intervals_bridge-0.2.0/tests/test_sync.py +292 -0
  36. garmin_intervals_bridge-0.2.0/tests/test_sync_enrich.py +101 -0
  37. garmin_intervals_bridge-0.2.0/tests/test_watch_backfill.py +96 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andreas Schnederle-Wagner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,317 @@
1
+ Metadata-Version: 2.4
2
+ Name: garmin-intervals-bridge
3
+ Version: 0.2.0
4
+ Summary: Self-hosted Garmin Connect original FIT and wellness sync to Intervals.icu
5
+ Author: Andreas Schnederle-Wagner
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/futureweb/garmin-intervals-bridge
8
+ Project-URL: Repository, https://github.com/futureweb/garmin-intervals-bridge
9
+ Project-URL: Issues, https://github.com/futureweb/garmin-intervals-bridge/issues
10
+ Project-URL: Changelog, https://github.com/futureweb/garmin-intervals-bridge/blob/main/CHANGELOG.md
11
+ Keywords: garmin,garmin-connect,intervals.icu,fit,wellness,sync,cycling,running
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.12
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: garminconnect<0.4,>=0.3.17
24
+ Requires-Dist: requests<3,>=2.31
25
+ Requires-Dist: garmin-fit-sdk>=21.171
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest<10,>=8; extra == "dev"
28
+ Requires-Dist: ruff>=0.6; extra == "dev"
29
+ Requires-Dist: pip-audit>=2.7; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # Garmin → Intervals.icu Bridge
33
+
34
+ **Garmin started filtering the FIT files it sends to Intervals.icu. This puts the data back — and syncs a lot more.**
35
+
36
+ > **TL;DR** — Since late September 2026 Garmin strips its own metrics (Stamina, Recovery Time,
37
+ > VO₂max, Performance Condition, Training Effect, Sweat Loss …) from the FIT files it sends to
38
+ > Intervals.icu. This tool fetches the original file from Garmin Connect and adds the missing
39
+ > data to the activity the official sync already created — no duplicates, nothing deleted,
40
+ > training load untouched. It also syncs the daily wellness data the official sync misses
41
+ > (night SpO₂, respiration, sleeping HR, Body Battery, HRV details, sleep stages, stress,
42
+ > readiness, logged nutrition and total burn, endurance & hill scores, race predictions,
43
+ > fitness age …), backfills the past, and comes with 19 charts for all of it in Intervals'
44
+ > chart library. Runs on Linux (systemd timers), in a container, or on **Windows with one
45
+ > command** ([guide](docs/WINDOWS.md)).
46
+
47
+ <!-- screenshots: docs/images/activity-before-after.png, docs/images/wellness-day.png, docs/images/fitness-chart.png -->
48
+
49
+ Since the end of September 2026 the file Garmin hands to partners is not
50
+ the file your device recorded. (Garmin first switched the filter on in
51
+ March 2026, rolled it back after a week of protest, and switched it back
52
+ on half a year later.) The recording survives (power, heart rate, GPS, cadence,
53
+ developer fields); Garmin's own metrics do not. Open the same activity in
54
+ Intervals.icu and in Garmin Connect and you will miss Performance
55
+ Condition, Stamina, Recovery Time, VO₂max, Training Effect and Sweat Loss.
56
+ The original file you can download from Garmin Connect still has them.
57
+
58
+ This bridge is a small self-hosted service that:
59
+
60
+ - fetches the **original FIT** through Garmin Connect's private API, byte-identical to the browser export;
61
+ - finds the activity the **official sync already created** in Intervals.icu — the sync stays on, nothing is duplicated, deleted or recomputed;
62
+ - adds exactly **what Garmin stripped**, into the custom fields and streams *you* have configured (Stamina curve, Recovery Time, VO₂max, Performance Condition, Sweat Loss, Training Effect …);
63
+ - fills the **daily wellness values the official sync does not deliver**: night SpO₂, respiration, sleeping heart rate, Body Battery, HRV details, sleep stages, stress, readiness, floors, scale data — and your **logged nutrition** (kcal, carbs, protein, fat);
64
+ - can **backfill the past** (on the reference account SpO₂ had silently stopped arriving in mid-2025);
65
+ - reacts within about a minute of the official import, with Garmin contacted only when there is something new.
66
+
67
+ Everything is a dry run until you say `--apply`. Values that exist are never
68
+ overwritten unless they demonstrably came from a filtered file.
69
+
70
+ ## What gets synced
71
+
72
+ | | Activities (enrich mode) | Daily wellness |
73
+ | --- | --- | --- |
74
+ | **Source** | Original FIT from Garmin Connect | Garmin's daily endpoints (23 of them) |
75
+ | **Target** | Your Intervals custom activity fields and custom streams, defined by you, read from your own definitions | Native Intervals wellness fields first, private `Garmin…` custom fields for the rest |
76
+ | **Examples** | Stamina / Potential Stamina streams, Recovery Time, VO₂max, Performance Condition, Sweat Loss, Aerobic/Anaerobic Effect, Stamina at start/end, EPOC, Training Load, grade-adjusted speed | SpO₂, respiration, sleeping HR, resting HR, HRV (+5-min high, 7-day avg), sleep seconds/score/stages, Body Battery max/min/charged/drained, training readiness, recovery time, acute load, stress, intensity minutes, floors, steps, hydration, sweat loss, weight, body fat, kcal consumed, total and active burn, carbohydrates, protein, fat (g and kcal), endurance & hill scores, fitness age, race predictions, VO₂max (run/bike) |
77
+ | **Rule** | Only what the partner copy lacks; aligned by timestamp; idempotent | Only empty values; locked days skipped; today's running totals wait until tomorrow |
78
+ | **Archive** | Original + partner copy of every activity | Raw JSON of every endpoint, every day |
79
+
80
+ Version 0.2.0. Verified end to end on one account (fenix 8, Edge 1040);
81
+ the first live writes and the evidence are recorded in
82
+ [docs/PLAN.md](docs/PLAN.md).
83
+
84
+ ## How it works
85
+
86
+ 1. **Original FIT.** `garminconnect` downloads the activity's original
87
+ export; the ZIP is unpacked in memory, the FIT is CRC-checked with
88
+ Garmin's official FIT SDK and archived unchanged.
89
+ 2. **Match.** The Intervals activity is found by `external_id` (the official
90
+ sync stores the Garmin activity ID) or, failing that, by source, start
91
+ time (±120 s) and duration (±5 %). Anything ambiguous is left alone.
92
+ 3. **Gap.** The file Intervals received (`GET /activity/{id}/file`) is
93
+ decoded next to the original. What only the original carries is the gap:
94
+ whole message types, record fields (stream candidates), session fields
95
+ (scalar candidates), developer fields, and Garmin-internal numeric IDs.
96
+ 4. **Write back.** *Your own custom items* say what to write and from where.
97
+ Intervals fills custom activity fields and custom streams from the FIT
98
+ itself, and every definition names its source (`fit_session_field:
99
+ "140.9"`, `fit_record_field: "stance_time"`, or a one-line script reading
100
+ `m.f_138`), plus an optional first-import conversion. The bridge reuses
101
+ those definitions verbatim: same fields, same source, same units, nothing
102
+ Garmin-specific hard-coded. Scalars go through `PUT /activity/{id}`,
103
+ streams through `PUT /activity/{id}/streams`, aligned to the activity's
104
+ `time` stream by timestamp.
105
+ 5. **Never overwrite real data.** An existing value is replaced only when
106
+ its FIT source is present in the original and absent from the partner
107
+ copy; then it cannot have come from data (Intervals stores `0` for a
108
+ stripped source). A second run writes nothing.
109
+
110
+ Garmin does not filter every activity. The gap is measured per activity
111
+ and never assumed.
112
+
113
+ ## Quick start
114
+
115
+ Python 3.12 or newer.
116
+
117
+ ```bash
118
+ python3 -m venv .venv && . .venv/bin/activate
119
+ pip install -e '.[dev]'
120
+ pytest -q # synthetic tests, no accounts needed
121
+
122
+ export INTERVALS_API_KEY=... # Intervals → Settings → Developer
123
+ export INTERVALS_ATHLETE_ID=0 # 0 = the key's owner
124
+ export BRIDGE_DATA_DIR=./data # FITs, raw JSON, SQLite, tokens
125
+
126
+ garmin-intervals-bridge login # once, interactive, MFA supported; stores tokens only
127
+ garmin-intervals-bridge probe # newest original FIT, archived; no Intervals access
128
+ garmin-intervals-bridge gap --activity-id <garmin id> # what did Garmin strip? read-only
129
+ garmin-intervals-bridge enrich --activity-id <garmin id> # dry run: the exact plan
130
+ garmin-intervals-bridge enrich --activity-id <garmin id> --apply
131
+ ```
132
+
133
+ Then let it run on its own:
134
+
135
+ ```bash
136
+ garmin-intervals-bridge watch --apply # one cheap Intervals poll; Garmin only on new activities
137
+ garmin-intervals-bridge sync --apply # full run over the last days, activities + wellness
138
+ ```
139
+
140
+ Everything is a dry run until `--apply` is given. The plan printed by a dry
141
+ run is exactly what `--apply` would send.
142
+
143
+ ## Commands
144
+
145
+ | Command | What it does |
146
+ | --- | --- |
147
+ | `login` | Interactive Garmin sign-in (email, password, MFA). Only session tokens are stored. |
148
+ | `probe [--activity-id]` | Download and archive one original FIT. |
149
+ | `gap --activity-id` | Original vs. the copy Intervals holds; lists everything only the original carries. |
150
+ | `compare-fit A B` | Same comparison for two local files (e.g. against a manual browser export). |
151
+ | `enrich --activity-id [--apply] [--refresh-own-streams]` | Plan or perform the enrichment of one activity. |
152
+ | `watch [--apply]` | Poll Intervals once (~450 bytes); enrich activities seen for the first time. |
153
+ | `sync [--scope all\|activities\|wellness] [--mode enrich\|upload] [--apply]` | Scheduled run over the last days. |
154
+ | `backfill --scope wellness\|activities --from DATE [--to DATE] [--apply]` | Paced, resumable run over the past. |
155
+ | `setup-fields [--apply]` | Create the private custom wellness fields the mapping uses. |
156
+ | `setup-charts [--apply]` | Create (and later complete) private fitness charts for the synced values. |
157
+ | `status` | Pending uploads and failed activities with their retry time. |
158
+ | `health` | Daily digest: probes both services and reports errors that would otherwise stay silent (dead timers, endpoints failing for days, fields Garmin stopped delivering, activities failing repeatedly). Exit 2 only when a human is needed. |
159
+
160
+ ## Modes
161
+
162
+ **enrich** (default). The official Garmin import stays on. Each imported
163
+ activity is completed with what its file lacks. This is the mode for
164
+ everyone who wants to keep Intervals' own import, training load and workout
165
+ pairing.
166
+
167
+ **upload** (`--mode upload`). The v0.1 behaviour for accounts that switched
168
+ the official activity import off: originals are posted as new activities,
169
+ guarded by a persistent *pending* state so a timed-out upload is never
170
+ replayed blindly, and blocked when an activity already exists nearby.
171
+ Intervals de-duplicates uploads by file hash, so with the official import
172
+ on this mode *would* create duplicates. Use it only with the import off.
173
+
174
+ ## Wellness
175
+
176
+ Garmin's daily endpoints (sleep, HRV, stress, Body Battery, readiness,
177
+ respiration, SpO₂, hydration, scale, nutrition, intensity minutes, scores,
178
+ predictions, fitness age, lactate threshold, …) are fetched per day; every
179
+ raw response is archived as JSON. Values are written to Intervals only where
180
+ the day has nothing yet, locked days are skipped, and running totals of the
181
+ current day wait for tomorrow.
182
+
183
+ Native Intervals fields filled when empty: `restingHR`, `hrv`, `sleepSecs`,
184
+ `sleepScore`, `readiness`, `vo2max`, `steps`, `floorsClimbed`,
185
+ `hydrationVolume`, `spO2`, `respiration`, `avgSleepingHR`, `weight`,
186
+ `bodyFat`, and from logged food `kcalConsumed`, `carbohydrates`, `protein`,
187
+ `fatTotal`. Everything else goes to private custom fields named `Garmin…`,
188
+ listed in [docs/FIELD_MAPPING.md](docs/FIELD_MAPPING.md).
189
+ `BRIDGE_WELLNESS_PROFILE=recommended` (default) leaves out a handful of
190
+ duplicates and goals; `all` keeps every code.
191
+
192
+ Backfill the past when the official sync has gaps (on the reference account
193
+ SpO₂ stopped arriving in mid-2025):
194
+
195
+ ```bash
196
+ garmin-intervals-bridge backfill --scope wellness --from 2025-01-01 # dry run
197
+ garmin-intervals-bridge backfill --scope wellness --from 2025-01-01 --pause 3 --apply
198
+ ```
199
+
200
+ A backfill uses the twelve per-day measurement endpoints by default
201
+ (`--endpoints all` for every endpoint), pauses between days, and skips days
202
+ already fetched, so it can be interrupted and resumed.
203
+
204
+ ## Charts
205
+
206
+ The bridge's nineteen fitness charts are published in Intervals' chart
207
+ library: Fitness page → a tab → *custom charts* → search for **Garmin
208
+ Bridge** and tick what you want: readiness & recovery, sleep stages, sleep
209
+ score & sleeping HR, SpO₂ & respiration, stress & Body Battery, HRV detail,
210
+ nutrition intake vs. burn, macro energy, energy balance per week and month
211
+ with weight, endurance & hill scores, VO₂max & fitness age, race
212
+ predictions, intensity minutes & sweat loss, hydration, steps, body
213
+ composition, skin temperature. Each is built from the fields the bridge
214
+ writes, with one axis per unit and values that hold until the next
215
+ measurement carried across the days in between.
216
+
217
+ Prefer your own copies? `setup-charts --apply` creates the same charts as
218
+ private items in your account, only with the fields that already exist,
219
+ and completes them later as fields appear (for example after a backfill
220
+ with every endpoint). Charts you made yourself are never touched, even with
221
+ the same name. Intervals' API cannot place a chart on a Fitness tab; that
222
+ last click is yours either way.
223
+
224
+ ## Running it
225
+
226
+ **systemd (recommended, no container):** an unprivileged service user, a
227
+ one-minute `watch` timer and a 30-minute full run, secrets in an
228
+ `EnvironmentFile`, hardened units. See [deploy/README.md](deploy/README.md).
229
+
230
+ **Windows, macOS or any PC without systemd:** `garmin-intervals-bridge run --apply`
231
+ keeps polling Intervals every minute, does a full run every 30 minutes and
232
+ a health probe once a day, all in one process. A step-by-step guide for
233
+ non-technical users is in [docs/WINDOWS.md](docs/WINDOWS.md).
234
+
235
+ **Docker / Podman:** the image runs as a non-root user and takes the same
236
+ environment variables.
237
+
238
+ ```bash
239
+ cp .env.example .env # fill INTERVALS_API_KEY
240
+ mkdir -p data && chown 10001:10001 data
241
+ docker compose build
242
+ docker compose run --rm bridge login
243
+ docker compose run --rm bridge enrich --activity-id <garmin id>
244
+ docker compose run --rm bridge sync --apply
245
+ ```
246
+
247
+ The compose service is deliberately not an always-on daemon; schedule
248
+ `docker compose run --rm -T bridge watch --apply` from cron or a timer.
249
+
250
+ ## Configuration
251
+
252
+ Variables can also come from a `.env` file in the working directory (or the
253
+ file named by `BRIDGE_ENV_FILE`); variables already set always win.
254
+
255
+ | Variable | Default | Meaning |
256
+ | --- | --- | --- |
257
+ | `INTERVALS_API_KEY` | | Intervals API key (HTTP Basic, user `API_KEY`) |
258
+ | `INTERVALS_ATHLETE_ID` | `0` | `0` addresses the key's owner |
259
+ | `BRIDGE_DATA_DIR` | `./data` | Originals, partner copies, raw wellness JSON, SQLite state, lock files |
260
+ | `GARMIN_TOKEN_DIR` | `$BRIDGE_DATA_DIR/tokens` | Garmin session tokens (mode 0700) |
261
+ | `BRIDGE_TIMEZONE` | `Europe/Vienna` | Your local day boundary |
262
+ | `BRIDGE_ACTIVITY_LOOKBACK_DAYS` | `4` | `sync` window for activities (1–30) |
263
+ | `BRIDGE_WELLNESS_LOOKBACK_DAYS` | `3` | `sync` window for wellness (1–30) |
264
+ | `BRIDGE_WELLNESS_REFRESH_HOURS` | `4` | Re-read today this often; yesterday once after midnight and then every second period; older days once after midnight |
265
+ | `BRIDGE_WELLNESS_PROFILE` | `recommended` | `recommended` or `all` custom fields |
266
+ | `BRIDGE_GARMIN_REQUEST_DELAY` | `0.5` | Seconds between Garmin requests (minimum 0.25) |
267
+ | `BRIDGE_STALE_HOURS` | `24` | `health` alerts once a service has failed this long |
268
+
269
+ ## Known limitations
270
+
271
+ - **Garmin's endpoints are private and undocumented.** They can change,
272
+ rate-limit or block without notice. The mobile login path answers 429
273
+ for some clients; the web path with MFA works. Keep request delays.
274
+ - **Writing streams marks the activity's intervals as edited**
275
+ (`icu_intervals_edited = true`, not resettable through the API).
276
+ Intervals then no longer regenerates that activity's intervals on
277
+ re-analysis. Harmless for an imported activity whose laps exist, but
278
+ permanent. Writing scalar fields does not trigger it.
279
+ - **Only definitions the bridge can read safely are used.** Field scripts
280
+ of the forms `activity.X / 60`, `activity.X == 0 ? NaN : activity.X / 36`
281
+ and stream scripts reading one `m.f_<n>` with constant factors are
282
+ understood; anything else is listed as unsupported and left alone.
283
+ - **Manual activities** (no device file) are skipped. **Strava-sourced**
284
+ activities are never touched.
285
+ - One Garmin account and one Intervals athlete per data directory.
286
+ - Intraday series (stress, heart rate, Body Battery curves) have no place
287
+ in Intervals' one-value-per-day wellness model; they stay in the raw
288
+ archive.
289
+
290
+ ## Troubleshooting
291
+
292
+ | Symptom | Meaning |
293
+ | --- | --- |
294
+ | `Garmin login needed` | Run `login` interactively in the same environment the service uses. |
295
+ | `429` during `login` | The mobile login path was refused; the web path usually follows. Do not retry in a loop. |
296
+ | `Another bridge instance is running (wellness)` | A backfill holds that scope's lock; activities keep running. |
297
+ | `outcome: unmatched` | The official import has not created the activity yet; the next run looks again. |
298
+ | `status` lists a *pending* upload | Upload mode: the request's outcome is unknown. Check Intervals before `reset-pending`. |
299
+ | `203/EXEC Permission denied` under systemd | SELinux: the virtualenv must not live under a web document root. |
300
+
301
+ ## Security and privacy
302
+
303
+ Read [SECURITY.md](SECURITY.md). In short: no passwords stored,
304
+ tokens and the API key readable by the service user only, the data
305
+ directory is personal (routes, health data) and must never be committed or
306
+ shared, dry run by default, no deletions ever.
307
+
308
+ ## Acknowledgements
309
+
310
+ [cyberjunky/python-garminconnect](https://github.com/cyberjunky/python-garminconnect),
311
+ Garmin's [FIT SDK](https://developer.garmin.com/fit/), the
312
+ [Intervals.icu API](https://intervals.icu/api/v1/docs), and the
313
+ [forum thread](https://forum.intervals.icu/t/garmin-fit-fields-not-coming-in-possible-garmin-is-filtering-data-prior-to-sending-to-intervals-icu/124287)
314
+ that documented the problem. Independent hobby project, not affiliated with
315
+ Garmin or Intervals.icu.
316
+
317
+ MIT licensed.
@@ -0,0 +1,286 @@
1
+ # Garmin → Intervals.icu Bridge
2
+
3
+ **Garmin started filtering the FIT files it sends to Intervals.icu. This puts the data back — and syncs a lot more.**
4
+
5
+ > **TL;DR** — Since late September 2026 Garmin strips its own metrics (Stamina, Recovery Time,
6
+ > VO₂max, Performance Condition, Training Effect, Sweat Loss …) from the FIT files it sends to
7
+ > Intervals.icu. This tool fetches the original file from Garmin Connect and adds the missing
8
+ > data to the activity the official sync already created — no duplicates, nothing deleted,
9
+ > training load untouched. It also syncs the daily wellness data the official sync misses
10
+ > (night SpO₂, respiration, sleeping HR, Body Battery, HRV details, sleep stages, stress,
11
+ > readiness, logged nutrition and total burn, endurance & hill scores, race predictions,
12
+ > fitness age …), backfills the past, and comes with 19 charts for all of it in Intervals'
13
+ > chart library. Runs on Linux (systemd timers), in a container, or on **Windows with one
14
+ > command** ([guide](docs/WINDOWS.md)).
15
+
16
+ <!-- screenshots: docs/images/activity-before-after.png, docs/images/wellness-day.png, docs/images/fitness-chart.png -->
17
+
18
+ Since the end of September 2026 the file Garmin hands to partners is not
19
+ the file your device recorded. (Garmin first switched the filter on in
20
+ March 2026, rolled it back after a week of protest, and switched it back
21
+ on half a year later.) The recording survives (power, heart rate, GPS, cadence,
22
+ developer fields); Garmin's own metrics do not. Open the same activity in
23
+ Intervals.icu and in Garmin Connect and you will miss Performance
24
+ Condition, Stamina, Recovery Time, VO₂max, Training Effect and Sweat Loss.
25
+ The original file you can download from Garmin Connect still has them.
26
+
27
+ This bridge is a small self-hosted service that:
28
+
29
+ - fetches the **original FIT** through Garmin Connect's private API, byte-identical to the browser export;
30
+ - finds the activity the **official sync already created** in Intervals.icu — the sync stays on, nothing is duplicated, deleted or recomputed;
31
+ - adds exactly **what Garmin stripped**, into the custom fields and streams *you* have configured (Stamina curve, Recovery Time, VO₂max, Performance Condition, Sweat Loss, Training Effect …);
32
+ - fills the **daily wellness values the official sync does not deliver**: night SpO₂, respiration, sleeping heart rate, Body Battery, HRV details, sleep stages, stress, readiness, floors, scale data — and your **logged nutrition** (kcal, carbs, protein, fat);
33
+ - can **backfill the past** (on the reference account SpO₂ had silently stopped arriving in mid-2025);
34
+ - reacts within about a minute of the official import, with Garmin contacted only when there is something new.
35
+
36
+ Everything is a dry run until you say `--apply`. Values that exist are never
37
+ overwritten unless they demonstrably came from a filtered file.
38
+
39
+ ## What gets synced
40
+
41
+ | | Activities (enrich mode) | Daily wellness |
42
+ | --- | --- | --- |
43
+ | **Source** | Original FIT from Garmin Connect | Garmin's daily endpoints (23 of them) |
44
+ | **Target** | Your Intervals custom activity fields and custom streams, defined by you, read from your own definitions | Native Intervals wellness fields first, private `Garmin…` custom fields for the rest |
45
+ | **Examples** | Stamina / Potential Stamina streams, Recovery Time, VO₂max, Performance Condition, Sweat Loss, Aerobic/Anaerobic Effect, Stamina at start/end, EPOC, Training Load, grade-adjusted speed | SpO₂, respiration, sleeping HR, resting HR, HRV (+5-min high, 7-day avg), sleep seconds/score/stages, Body Battery max/min/charged/drained, training readiness, recovery time, acute load, stress, intensity minutes, floors, steps, hydration, sweat loss, weight, body fat, kcal consumed, total and active burn, carbohydrates, protein, fat (g and kcal), endurance & hill scores, fitness age, race predictions, VO₂max (run/bike) |
46
+ | **Rule** | Only what the partner copy lacks; aligned by timestamp; idempotent | Only empty values; locked days skipped; today's running totals wait until tomorrow |
47
+ | **Archive** | Original + partner copy of every activity | Raw JSON of every endpoint, every day |
48
+
49
+ Version 0.2.0. Verified end to end on one account (fenix 8, Edge 1040);
50
+ the first live writes and the evidence are recorded in
51
+ [docs/PLAN.md](docs/PLAN.md).
52
+
53
+ ## How it works
54
+
55
+ 1. **Original FIT.** `garminconnect` downloads the activity's original
56
+ export; the ZIP is unpacked in memory, the FIT is CRC-checked with
57
+ Garmin's official FIT SDK and archived unchanged.
58
+ 2. **Match.** The Intervals activity is found by `external_id` (the official
59
+ sync stores the Garmin activity ID) or, failing that, by source, start
60
+ time (±120 s) and duration (±5 %). Anything ambiguous is left alone.
61
+ 3. **Gap.** The file Intervals received (`GET /activity/{id}/file`) is
62
+ decoded next to the original. What only the original carries is the gap:
63
+ whole message types, record fields (stream candidates), session fields
64
+ (scalar candidates), developer fields, and Garmin-internal numeric IDs.
65
+ 4. **Write back.** *Your own custom items* say what to write and from where.
66
+ Intervals fills custom activity fields and custom streams from the FIT
67
+ itself, and every definition names its source (`fit_session_field:
68
+ "140.9"`, `fit_record_field: "stance_time"`, or a one-line script reading
69
+ `m.f_138`), plus an optional first-import conversion. The bridge reuses
70
+ those definitions verbatim: same fields, same source, same units, nothing
71
+ Garmin-specific hard-coded. Scalars go through `PUT /activity/{id}`,
72
+ streams through `PUT /activity/{id}/streams`, aligned to the activity's
73
+ `time` stream by timestamp.
74
+ 5. **Never overwrite real data.** An existing value is replaced only when
75
+ its FIT source is present in the original and absent from the partner
76
+ copy; then it cannot have come from data (Intervals stores `0` for a
77
+ stripped source). A second run writes nothing.
78
+
79
+ Garmin does not filter every activity. The gap is measured per activity
80
+ and never assumed.
81
+
82
+ ## Quick start
83
+
84
+ Python 3.12 or newer.
85
+
86
+ ```bash
87
+ python3 -m venv .venv && . .venv/bin/activate
88
+ pip install -e '.[dev]'
89
+ pytest -q # synthetic tests, no accounts needed
90
+
91
+ export INTERVALS_API_KEY=... # Intervals → Settings → Developer
92
+ export INTERVALS_ATHLETE_ID=0 # 0 = the key's owner
93
+ export BRIDGE_DATA_DIR=./data # FITs, raw JSON, SQLite, tokens
94
+
95
+ garmin-intervals-bridge login # once, interactive, MFA supported; stores tokens only
96
+ garmin-intervals-bridge probe # newest original FIT, archived; no Intervals access
97
+ garmin-intervals-bridge gap --activity-id <garmin id> # what did Garmin strip? read-only
98
+ garmin-intervals-bridge enrich --activity-id <garmin id> # dry run: the exact plan
99
+ garmin-intervals-bridge enrich --activity-id <garmin id> --apply
100
+ ```
101
+
102
+ Then let it run on its own:
103
+
104
+ ```bash
105
+ garmin-intervals-bridge watch --apply # one cheap Intervals poll; Garmin only on new activities
106
+ garmin-intervals-bridge sync --apply # full run over the last days, activities + wellness
107
+ ```
108
+
109
+ Everything is a dry run until `--apply` is given. The plan printed by a dry
110
+ run is exactly what `--apply` would send.
111
+
112
+ ## Commands
113
+
114
+ | Command | What it does |
115
+ | --- | --- |
116
+ | `login` | Interactive Garmin sign-in (email, password, MFA). Only session tokens are stored. |
117
+ | `probe [--activity-id]` | Download and archive one original FIT. |
118
+ | `gap --activity-id` | Original vs. the copy Intervals holds; lists everything only the original carries. |
119
+ | `compare-fit A B` | Same comparison for two local files (e.g. against a manual browser export). |
120
+ | `enrich --activity-id [--apply] [--refresh-own-streams]` | Plan or perform the enrichment of one activity. |
121
+ | `watch [--apply]` | Poll Intervals once (~450 bytes); enrich activities seen for the first time. |
122
+ | `sync [--scope all\|activities\|wellness] [--mode enrich\|upload] [--apply]` | Scheduled run over the last days. |
123
+ | `backfill --scope wellness\|activities --from DATE [--to DATE] [--apply]` | Paced, resumable run over the past. |
124
+ | `setup-fields [--apply]` | Create the private custom wellness fields the mapping uses. |
125
+ | `setup-charts [--apply]` | Create (and later complete) private fitness charts for the synced values. |
126
+ | `status` | Pending uploads and failed activities with their retry time. |
127
+ | `health` | Daily digest: probes both services and reports errors that would otherwise stay silent (dead timers, endpoints failing for days, fields Garmin stopped delivering, activities failing repeatedly). Exit 2 only when a human is needed. |
128
+
129
+ ## Modes
130
+
131
+ **enrich** (default). The official Garmin import stays on. Each imported
132
+ activity is completed with what its file lacks. This is the mode for
133
+ everyone who wants to keep Intervals' own import, training load and workout
134
+ pairing.
135
+
136
+ **upload** (`--mode upload`). The v0.1 behaviour for accounts that switched
137
+ the official activity import off: originals are posted as new activities,
138
+ guarded by a persistent *pending* state so a timed-out upload is never
139
+ replayed blindly, and blocked when an activity already exists nearby.
140
+ Intervals de-duplicates uploads by file hash, so with the official import
141
+ on this mode *would* create duplicates. Use it only with the import off.
142
+
143
+ ## Wellness
144
+
145
+ Garmin's daily endpoints (sleep, HRV, stress, Body Battery, readiness,
146
+ respiration, SpO₂, hydration, scale, nutrition, intensity minutes, scores,
147
+ predictions, fitness age, lactate threshold, …) are fetched per day; every
148
+ raw response is archived as JSON. Values are written to Intervals only where
149
+ the day has nothing yet, locked days are skipped, and running totals of the
150
+ current day wait for tomorrow.
151
+
152
+ Native Intervals fields filled when empty: `restingHR`, `hrv`, `sleepSecs`,
153
+ `sleepScore`, `readiness`, `vo2max`, `steps`, `floorsClimbed`,
154
+ `hydrationVolume`, `spO2`, `respiration`, `avgSleepingHR`, `weight`,
155
+ `bodyFat`, and from logged food `kcalConsumed`, `carbohydrates`, `protein`,
156
+ `fatTotal`. Everything else goes to private custom fields named `Garmin…`,
157
+ listed in [docs/FIELD_MAPPING.md](docs/FIELD_MAPPING.md).
158
+ `BRIDGE_WELLNESS_PROFILE=recommended` (default) leaves out a handful of
159
+ duplicates and goals; `all` keeps every code.
160
+
161
+ Backfill the past when the official sync has gaps (on the reference account
162
+ SpO₂ stopped arriving in mid-2025):
163
+
164
+ ```bash
165
+ garmin-intervals-bridge backfill --scope wellness --from 2025-01-01 # dry run
166
+ garmin-intervals-bridge backfill --scope wellness --from 2025-01-01 --pause 3 --apply
167
+ ```
168
+
169
+ A backfill uses the twelve per-day measurement endpoints by default
170
+ (`--endpoints all` for every endpoint), pauses between days, and skips days
171
+ already fetched, so it can be interrupted and resumed.
172
+
173
+ ## Charts
174
+
175
+ The bridge's nineteen fitness charts are published in Intervals' chart
176
+ library: Fitness page → a tab → *custom charts* → search for **Garmin
177
+ Bridge** and tick what you want: readiness & recovery, sleep stages, sleep
178
+ score & sleeping HR, SpO₂ & respiration, stress & Body Battery, HRV detail,
179
+ nutrition intake vs. burn, macro energy, energy balance per week and month
180
+ with weight, endurance & hill scores, VO₂max & fitness age, race
181
+ predictions, intensity minutes & sweat loss, hydration, steps, body
182
+ composition, skin temperature. Each is built from the fields the bridge
183
+ writes, with one axis per unit and values that hold until the next
184
+ measurement carried across the days in between.
185
+
186
+ Prefer your own copies? `setup-charts --apply` creates the same charts as
187
+ private items in your account, only with the fields that already exist,
188
+ and completes them later as fields appear (for example after a backfill
189
+ with every endpoint). Charts you made yourself are never touched, even with
190
+ the same name. Intervals' API cannot place a chart on a Fitness tab; that
191
+ last click is yours either way.
192
+
193
+ ## Running it
194
+
195
+ **systemd (recommended, no container):** an unprivileged service user, a
196
+ one-minute `watch` timer and a 30-minute full run, secrets in an
197
+ `EnvironmentFile`, hardened units. See [deploy/README.md](deploy/README.md).
198
+
199
+ **Windows, macOS or any PC without systemd:** `garmin-intervals-bridge run --apply`
200
+ keeps polling Intervals every minute, does a full run every 30 minutes and
201
+ a health probe once a day, all in one process. A step-by-step guide for
202
+ non-technical users is in [docs/WINDOWS.md](docs/WINDOWS.md).
203
+
204
+ **Docker / Podman:** the image runs as a non-root user and takes the same
205
+ environment variables.
206
+
207
+ ```bash
208
+ cp .env.example .env # fill INTERVALS_API_KEY
209
+ mkdir -p data && chown 10001:10001 data
210
+ docker compose build
211
+ docker compose run --rm bridge login
212
+ docker compose run --rm bridge enrich --activity-id <garmin id>
213
+ docker compose run --rm bridge sync --apply
214
+ ```
215
+
216
+ The compose service is deliberately not an always-on daemon; schedule
217
+ `docker compose run --rm -T bridge watch --apply` from cron or a timer.
218
+
219
+ ## Configuration
220
+
221
+ Variables can also come from a `.env` file in the working directory (or the
222
+ file named by `BRIDGE_ENV_FILE`); variables already set always win.
223
+
224
+ | Variable | Default | Meaning |
225
+ | --- | --- | --- |
226
+ | `INTERVALS_API_KEY` | | Intervals API key (HTTP Basic, user `API_KEY`) |
227
+ | `INTERVALS_ATHLETE_ID` | `0` | `0` addresses the key's owner |
228
+ | `BRIDGE_DATA_DIR` | `./data` | Originals, partner copies, raw wellness JSON, SQLite state, lock files |
229
+ | `GARMIN_TOKEN_DIR` | `$BRIDGE_DATA_DIR/tokens` | Garmin session tokens (mode 0700) |
230
+ | `BRIDGE_TIMEZONE` | `Europe/Vienna` | Your local day boundary |
231
+ | `BRIDGE_ACTIVITY_LOOKBACK_DAYS` | `4` | `sync` window for activities (1–30) |
232
+ | `BRIDGE_WELLNESS_LOOKBACK_DAYS` | `3` | `sync` window for wellness (1–30) |
233
+ | `BRIDGE_WELLNESS_REFRESH_HOURS` | `4` | Re-read today this often; yesterday once after midnight and then every second period; older days once after midnight |
234
+ | `BRIDGE_WELLNESS_PROFILE` | `recommended` | `recommended` or `all` custom fields |
235
+ | `BRIDGE_GARMIN_REQUEST_DELAY` | `0.5` | Seconds between Garmin requests (minimum 0.25) |
236
+ | `BRIDGE_STALE_HOURS` | `24` | `health` alerts once a service has failed this long |
237
+
238
+ ## Known limitations
239
+
240
+ - **Garmin's endpoints are private and undocumented.** They can change,
241
+ rate-limit or block without notice. The mobile login path answers 429
242
+ for some clients; the web path with MFA works. Keep request delays.
243
+ - **Writing streams marks the activity's intervals as edited**
244
+ (`icu_intervals_edited = true`, not resettable through the API).
245
+ Intervals then no longer regenerates that activity's intervals on
246
+ re-analysis. Harmless for an imported activity whose laps exist, but
247
+ permanent. Writing scalar fields does not trigger it.
248
+ - **Only definitions the bridge can read safely are used.** Field scripts
249
+ of the forms `activity.X / 60`, `activity.X == 0 ? NaN : activity.X / 36`
250
+ and stream scripts reading one `m.f_<n>` with constant factors are
251
+ understood; anything else is listed as unsupported and left alone.
252
+ - **Manual activities** (no device file) are skipped. **Strava-sourced**
253
+ activities are never touched.
254
+ - One Garmin account and one Intervals athlete per data directory.
255
+ - Intraday series (stress, heart rate, Body Battery curves) have no place
256
+ in Intervals' one-value-per-day wellness model; they stay in the raw
257
+ archive.
258
+
259
+ ## Troubleshooting
260
+
261
+ | Symptom | Meaning |
262
+ | --- | --- |
263
+ | `Garmin login needed` | Run `login` interactively in the same environment the service uses. |
264
+ | `429` during `login` | The mobile login path was refused; the web path usually follows. Do not retry in a loop. |
265
+ | `Another bridge instance is running (wellness)` | A backfill holds that scope's lock; activities keep running. |
266
+ | `outcome: unmatched` | The official import has not created the activity yet; the next run looks again. |
267
+ | `status` lists a *pending* upload | Upload mode: the request's outcome is unknown. Check Intervals before `reset-pending`. |
268
+ | `203/EXEC Permission denied` under systemd | SELinux: the virtualenv must not live under a web document root. |
269
+
270
+ ## Security and privacy
271
+
272
+ Read [SECURITY.md](SECURITY.md). In short: no passwords stored,
273
+ tokens and the API key readable by the service user only, the data
274
+ directory is personal (routes, health data) and must never be committed or
275
+ shared, dry run by default, no deletions ever.
276
+
277
+ ## Acknowledgements
278
+
279
+ [cyberjunky/python-garminconnect](https://github.com/cyberjunky/python-garminconnect),
280
+ Garmin's [FIT SDK](https://developer.garmin.com/fit/), the
281
+ [Intervals.icu API](https://intervals.icu/api/v1/docs), and the
282
+ [forum thread](https://forum.intervals.icu/t/garmin-fit-fields-not-coming-in-possible-garmin-is-filtering-data-prior-to-sending-to-intervals-icu/124287)
283
+ that documented the problem. Independent hobby project, not affiliated with
284
+ Garmin or Intervals.icu.
285
+
286
+ MIT licensed.