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.
- garmin_intervals_bridge-0.2.0/LICENSE +21 -0
- garmin_intervals_bridge-0.2.0/PKG-INFO +317 -0
- garmin_intervals_bridge-0.2.0/README.md +286 -0
- garmin_intervals_bridge-0.2.0/pyproject.toml +54 -0
- garmin_intervals_bridge-0.2.0/setup.cfg +4 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/__init__.py +2 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/__main__.py +6 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/charts.py +430 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/cli.py +345 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/config.py +91 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/enrich.py +575 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/fit.py +175 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/garmin.py +237 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/health.py +148 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/intervals.py +216 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/mapping.py +273 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/store.py +286 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/sync.py +375 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge/times.py +37 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge.egg-info/PKG-INFO +317 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge.egg-info/SOURCES.txt +35 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge.egg-info/dependency_links.txt +1 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge.egg-info/entry_points.txt +2 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge.egg-info/requires.txt +8 -0
- garmin_intervals_bridge-0.2.0/src/garmin_intervals_bridge.egg-info/top_level.txt +1 -0
- garmin_intervals_bridge-0.2.0/tests/test_charts.py +171 -0
- garmin_intervals_bridge-0.2.0/tests/test_enrich.py +210 -0
- garmin_intervals_bridge-0.2.0/tests/test_fit.py +107 -0
- garmin_intervals_bridge-0.2.0/tests/test_garmin_lazy.py +18 -0
- garmin_intervals_bridge-0.2.0/tests/test_health.py +112 -0
- garmin_intervals_bridge-0.2.0/tests/test_intervals.py +113 -0
- garmin_intervals_bridge-0.2.0/tests/test_mapping.py +137 -0
- garmin_intervals_bridge-0.2.0/tests/test_review_fixes.py +374 -0
- garmin_intervals_bridge-0.2.0/tests/test_store_locks.py +17 -0
- garmin_intervals_bridge-0.2.0/tests/test_sync.py +292 -0
- garmin_intervals_bridge-0.2.0/tests/test_sync_enrich.py +101 -0
- 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.
|