- Python 99.5%
- Shell 0.3%
- Dockerfile 0.2%
Follow-up to
|
||
|---|---|---|
| scripts | ||
| src | ||
| tests | ||
| .gitignore | ||
| claude.md | ||
| config.toml.example | ||
| docker-compose.yml | ||
| Dockerfile | ||
| entrypoint.sh | ||
| Garmin-Connect_paywright-bypass.md | ||
| garminnostra_avatar.png | ||
| LICENSE | ||
| pytest.ini | ||
| README.md | ||
| requirements-dev.txt | ||
| requirements.txt | ||
garmin-nostra
Dockerised Python service that automatically syncs Garmin Connect and/or Wahoo activities for multiple users.
For each new activity it:
- stores all metrics in a local SQLite database
- downloads and saves the GPX file
- renders a map image of the GPS track (OpenStreetMap tiles)
- posts a Mastodon mention to the user with key stats and the map (visibility per-user configurable: public, unlisted, or direct DM)
- optionally pushes a CalDAV event to e.g. Nextcloud calendar
Messages and calendar entries are formatted in German with metric units.
Features
| Feature | Details |
|---|---|
| Multi-user | One [[users]] block per account (Garmin or Wahoo) |
| Wahoo support | Sync from Wahoo Cloud API; optionally upload activities to Garmin Connect |
| Conditional sync | Optional per-user, per-source opt-in: sync only activities whose name contains a keyword, and rename an activity later to change your mind |
| Mastodon post | Bot mentions the user; visibility is public, unlisted, or direct (DM) per user |
| Activity stats | Duration, distance, pace/speed, elevation, power, heart rate |
| Map image | GPX track rendered as PNG, attached to the DM |
| Elevation profile | Elevation + speed profile PNG for distance-based activities |
| GPX + FIT files | Original GPX and FIT files downloaded and stored per activity |
| Track signatures | Grid-cell GPS signature precomputed per activity for route-similarity comparison |
| Insights | Per-kilometre pace/HR/cadence/power splits + negative-split/HR-drift flags, precomputed at sync time |
| KudosMachine | Polls activity posts for favourites and auto-replies with a kudos message mentioning the fav-giver; 100 random German messages or a custom template. Only active when mastodon_public is true or "listed" — DMs cannot be favourited. |
| CalDAV | Optional per-user; pushes VEVENT to an iCal compatible calendar |
| SQLite | All Garmin data stored; queryable by user, type, time |
| Token caching | Garmin OAuth tokens persisted per user — avoids repeated logins |
| Retry | Failed integrations (CalDAV/Mastodon) are retried on the next run |
Message format
The bot posts a Mastodon mention to each user. Visibility is direct (DM) by default — only the mentioned user sees the post. Set mastodon_public = "listed" for unlisted posts (visible to followers and via link), or mastodon_public = true for fully public posts.
Running example:
🏃 Morgenlauf – Di., 04. März 2025, 07:15 Uhr
⏱ 45:32 📏 8,50 km 💨 5:21 min/km
📈 115 m Anstieg ❤️ Ø 148 bpm
#Laufen #GarminNostra @alice@fosstodon.org
Cycling example:
🚴 Nachmittagsfahrt – Di., 04. März 2025, 14:30 Uhr
⏱ 1:12:40 📏 38,20 km 💨 31,6 km/h
📈 540 m Anstieg ⚡ Ø 210 W ❤️ Ø 142 bpm
#Radfahren #GarminNostra @bob@mastodon.social
Attached: a 800×600 PNG map of the GPS track.
Quick start
1. Clone and configure
git clone https://github.com/vinzgreg/garmin_nostra.git
cd garmin-nostra
cp config.toml.example config.toml
$EDITOR config.toml // editor like nano, vim...
2. Create the data directory
I have this data directory as part of my home directory. Don't be confused, in the config-file it will refer to it as /data... not ~/data.
mkdir -p ~/data/garminnostra ~/data/garmin-tokens
Garmin/Wahoo OAuth tokens live in ~/data/garmin-tokens, a separate directory from the rest of the data (~/data/garminnostra). Keeping it separate means anything that needs read access to the database, GPX, FIT, or map files can bind-mount ~/data/garminnostra as a whole without ever being able to see the tokens.
The container runs as non-root user appuser (UID 1000). If your host user has a different UID, adjust ownership:
sudo chown -R 1000:1000 ~/data/garminnostra ~/data/garmin-tokens
3. Bootstrap Garmin tokens
Garmin's SSO is protected by Cloudflare and blocks programmatic logins. You need to bootstrap OAuth tokens once per Garmin user using your real browser. After that, tokens refresh automatically for up to a year.
# Create a temporary venv (runs on the host, not in Docker)
python3 -m venv /tmp/garmin-bootstrap
source /tmp/garmin-bootstrap/bin/activate
pip install requests
# Run once per Garmin user — replace 'betty' with the user's name from config.toml.
# Tokens are written to <token-base>/betty automatically, where <token-base> is
# the host path backing the container's /tokens mount (read from docker-compose.yml,
# i.e. ~/data/garmin-tokens):
python3 src/bootstrap_auth.py betty
# Optionally specify a browser (default: system default):
python3 src/bootstrap_auth.py betty --browser firefox
# Or override the location explicitly:
python3 src/bootstrap_auth.py -o ~/data/garmin-tokens/betty
The script opens a browser to the Garmin SSO login page.
Before logging in:
- Press F12 to open DevTools, go to the Console tab
- In Firefox, type
allow pastingand press Enter first (ignore the error) - Paste and run:
window.addEventListener('beforeunload', function(e) { e.preventDefault(); e.returnValue = ''; }); - Log in normally (complete any CAPTCHA or MFA)
- A "Leave this page?" dialog appears — click Stay on Page
- Switch to the Network tab, filter by
login - Click the
/portal/api/loginrequest, go to the Response tab - Copy the
serviceTicketIdvalue (ST-xxxxx) - Paste it into the terminal
Tokens are saved to the output directory as garmin_tokens.json (permissions 0600).
# Clean up the temporary venv
deactivate
rm -rf /tmp/garmin-bootstrap
Re-run this step if tokens expire or you add a new Garmin user. See Garmin-Connect_paywright-bypass.md for more detail.
4. Build and start
docker compose up -d --build
docker compose logs -f
On first start the container runs an immediate sync, then loops at the configured interval_minutes.
Wahoo setup
To sync activities from a Wahoo account you need a Wahoo developer app and an OAuth refresh token. This is a one-time setup per user.
1. Register a Wahoo developer app
-
Go to developers.wahooligan.com/cloud and sign in with your Wahoo account.
-
Create a new application with the following settings:
Field Value Redirect URI https://localhostEnvironment Sandbox (switch to Production once confirmed working) Confidential Yes Webhook Leave blank — not needed (garmin-nostra polls the API) -
Note the Client ID and Client Secret from the app page.
2. Obtain a refresh token
Run the bootstrap helper (on the host or inside the container):
# On the host (if you have Python + requests installed):
python3 src/wahoo_auth.py <client_id> <client_secret>
# Or inside a running container:
docker exec -it garmin-nostra python3 /app/src/wahoo_auth.py <client_id> <client_secret>
The script will:
- Print an authorization URL and open it in your browser.
- After you authorize, the browser redirects to
https://localhost?code=…— the page will not load (this is expected). - Copy the
codeparameter from the browser's address bar and paste it back into the script. - The script prints your refresh token.
Note: Wahoo refresh tokens expire after 60 days of inactivity. As long as garmin-nostra syncs regularly, the token is refreshed automatically. If sync is paused for more than 60 days, re-run the bootstrap.
3. Configure the user
There are three source modes. Choose the one that fits your setup:
source = "wahoo" — Wahoo only
[[users]]
name = "carol"
source = "wahoo"
wahoo_client_id = "env:CAROL_WAHOO_CLIENT_ID"
wahoo_client_secret = "env:CAROL_WAHOO_CLIENT_SECRET"
wahoo_refresh_token = "env:CAROL_WAHOO_REFRESH_TOKEN"
mastodon_handle = "@carol@mastodon.social"
Store the actual values as environment variables in docker-compose.yml:
environment:
- CAROL_WAHOO_CLIENT_ID=your_client_id
- CAROL_WAHOO_CLIENT_SECRET=your_client_secret
- CAROL_WAHOO_REFRESH_TOKEN=your_refresh_token
source = "garmin" — Garmin only (default)
[[users]]
name = "alice"
garmin_username = "alice@example.com"
garmin_password = "env:ALICE_GARMIN_PASSWORD"
mastodon_handle = "@alice@mastodon.social"
source = "both" — Wahoo and Garmin, deduplicated
Syncs from both platforms in a single user block. Wahoo is processed first.
Activities are tagged [Wahoo] or [Garmin] in the database and Mastodon posts.
No cross-platform upload happens — each activity stays on its original source.
If a Wahoo workout was auto-synced to Garmin (e.g. via the native Wahoo→Garmin integration), garmin-nostra detects the duplicate by matching start times (±2 minute window) and skips the Garmin copy. Nothing is posted or stored twice.
[[users]]
name = "dave"
source = "both"
wahoo_client_id = "your_client_id_here"
wahoo_client_secret = "your_client_secret_here"
wahoo_refresh_token = "your_refresh_token_here"
garmin_username = "dave@example.com"
garmin_password = "your_garmin_password_here"
mastodon_handle = "@dave@mastodon.social"
4. Optional: sync Wahoo activities to Garmin Connect
Add wahoo_sync_to_garmin = true to upload Wahoo activities to Garmin Connect as FIT files. Works with source = "wahoo" or source = "both".
wahoo_sync_to_garmin = true
garmin_username = "carol@example.com"
garmin_password = "env:CAROL_GARMIN_PASSWORD"
If Wahoo has already auto-synced an activity to Garmin natively, the duplicate is detected and skipped.
Example for source = "both" with Wahoo pushed to Garmin:
[[users]]
name = "eve"
source = "both"
wahoo_sync_to_garmin = true
wahoo_client_id = "your_client_id_here"
wahoo_client_secret = "your_client_secret_here"
wahoo_refresh_token = "your_refresh_token_here"
garmin_username = "eve@example.com"
garmin_password = "your_garmin_password_here"
mastodon_handle = "@eve@mastodon.social"
Operations
Logs
# All output (startup, sync runs) goes to Docker's log
docker logs garmin-nostra -f
To also persist logs to a file, add log_file = "/data/garmin_nostra.log" to the [storage] section of config.toml, then tail it:
docker exec garmin-nostra tail -f /data/garmin_nostra.log
Manual sync
Trigger a sync immediately without waiting for the next scheduled run:
docker exec garmin-nostra python3 /app/src/sync.py /app/config.toml
Inspect the database
# Open an interactive SQLite shell
docker exec -it garmin-nostra sqlite3 /data/garmin_nostra.db
# Total activity count and number of users
docker exec garmin-nostra sqlite3 /data/garmin_nostra.db \
"SELECT count(distinct garmin_activity_id) as activity_count,
count(distinct user_id) as unique_users
FROM activities;"
# Recent activities (last 20)
docker exec garmin-nostra sqlite3 /data/garmin_nostra.db \
"SELECT garmin_activity_id, user_id, start_time_utc, activity_type
FROM activities ORDER BY start_time_utc DESC LIMIT 20;"
# Check which activities have been posted to Mastodon
docker exec garmin-nostra sqlite3 /data/garmin_nostra.db \
"SELECT garmin_activity_id, user_id, mastodon_posted, caldav_pushed
FROM activities ORDER BY start_time_utc DESC LIMIT 20;"
# Pending Mastodon posts
docker exec garmin-nostra sqlite3 /data/garmin_nostra.db \
"SELECT u.name, a.garmin_activity_id, a.activity_type, a.start_time_utc
FROM activities a JOIN users u ON a.user_id = u.id
WHERE a.mastodon_posted = 0 ORDER BY a.start_time_utc;"
Apply configuration changes
config.toml is mounted read-only; edit it on the host then restart:
docker compose restart garmin-nostra
A change to interval_minutes requires a restart so the new sleep interval takes effect.
Rebuild after code changes
docker compose up -d --build
Stop / remove
docker compose down # stop and remove the container (data on host is kept)
Configuration
All settings live in config.toml (git-ignored). See config.toml.example for a full template.
[bot]
| Key | Description |
|---|---|
mastodon_api_base_url |
Base URL of the Mastodon instance the bot account lives on |
mastodon_access_token |
OAuth access token for the bot account |
kudosCustom |
(optional) Custom kudos reply template. Supports {fav_giver} and {activity_user} placeholders. If omitted, a random message from the built-in pool of 100 is used. |
Create the bot account on your preferred instance, go to Preferences → Development → New application, grant the following scopes, and copy the access token:
| Scope | Purpose |
|---|---|
read:statuses |
Fetch who favourited an activity post (KudosMachine) |
write:statuses |
Post activity summaries and kudos replies |
write:media |
Upload map images |
[sync]
| Key | Default | Description |
|---|---|---|
interval_minutes |
60 |
How often the cron job runs |
lookback_days |
30 |
How far back to look on first run per user |
gpx_max_age_days |
(unset) | Skip GPX download for activities older than N days; omit to always download |
fit_max_age_days |
(unset) | Skip FIT download for activities older than N days; omit to always download |
mastodon_max_age_days |
(unset) | Skip Mastodon posts for activities older than N days (avoids rate limits on backfill) |
mastodon_post_delay_s |
2.0 |
Seconds to wait between consecutive Mastodon posts (avoids rate limits) |
request_timeout_s |
30 |
Timeout in seconds for all external HTTP calls |
pause_start |
(unset) | Pause sync after this local time (HH:MM); requires pause_end |
pause_end |
(unset) | Resume sync at this local time (HH:MM); requires pause_start |
[storage]
| Key | Example value | Description |
|---|---|---|
db_path |
/data/garmin_nostra.db |
SQLite database |
gpx_dir |
/data/gpx |
GPX files |
fit_dir |
/data/fit |
FIT files |
map_dir |
/data/maps |
Map images |
token_dir |
/tokens |
Garmin OAuth tokens (one subdirectory per user name) |
log_level |
info |
Log verbosity: debug, info, or error |
log_file |
/data/garmin_nostra.log |
(optional) Write logs to this file in addition to stdout |
Critical:
db_path,gpx_dir,fit_dir,map_dir, andlog_filemust start with/data/— the container's access to that data comes from the volume mount~/data/garminnostra → /data.token_diris the one exception: it must start with/tokens, backed by its own separate mount,~/data/garmin-tokens → /tokens(see Data directory layout for why it's split out). Do not use~,/home/vinz/..., or any other host path for any of these — those paths do not exist inside the container and the token/file lookup will silently fail.The corresponding host paths are:
Container path Host path /data/garmin_nostra.db~/data/garminnostra/garmin_nostra.db/data/gpx/~/data/garminnostra/gpx//data/fit/~/data/garminnostra/fit//data/maps/~/data/garminnostra/maps//tokens/<name>/~/data/garmin-tokens/<name>/
[caldav] (optional)
Remove this section to disable CalDAV globally. Individual users also need caldav_enabled = true.
| Key | Description |
|---|---|
url |
CalDAV root, e.g. https://nextcloud.example.com/remote.php/dav |
username |
Nextcloud username |
password |
Nextcloud password (or app password) |
calendar_name |
Name of the target calendar (must already exist) |
[[users]]
One block per account (Garmin or Wahoo):
| Key | Required | Description |
|---|---|---|
name |
✓ | Unique identifier used for file/token paths |
source |
"garmin" |
"garmin" (default), "wahoo", or "both" — see source modes |
garmin_username |
Garmin/sync | Garmin Connect e-mail (required for source = "garmin" or wahoo_sync_to_garmin) |
garmin_password |
Garmin/sync | Garmin Connect password |
wahoo_client_id |
Wahoo | Wahoo developer app client ID |
wahoo_client_secret |
Wahoo | Wahoo developer app client secret |
wahoo_refresh_token |
Wahoo | OAuth refresh token (obtained via wahoo_auth.py) |
wahoo_sync_to_garmin |
false |
Upload Wahoo activities to Garmin Connect (requires Garmin credentials) |
mastodon_handle |
— | @user@instance — the bot will mention this handle |
mastodon_public |
false |
Controls post visibility: true = public (on public timeline); "listed" = unlisted (followers + link, not on timeline); false = direct DM (only the mentioned user sees it) |
mastodon_add_mention |
— | (optional) Additional Mastodon handles to include in the post, e.g. "@coach@mastodon.social, @partner@fosstodon.org". Only useful with mastodon_public = false — all mentioned handles receive the DM. Accepts a comma- or space-separated string. |
mastodon_suppress_types |
[] |
List of glob patterns (case-insensitive) to suppress Mastodon posts for matching activity types. Example: ["*pilates*", "*strength*", "yoga"]. Wildcards: * matches any characters, ? matches one character. Suppressed activities are marked as posted (no retry). |
caldav_enabled |
false |
Set true to push CalDAV events for this user |
suppressKudos |
false |
Set true to opt this user out of kudos replies. KudosMachine is also automatically skipped when mastodon_public = false (DMs cannot be favourited). |
conditional_sync_garmin_key |
— | Sync only Garmin activities whose name contains this keyword — see conditional sync |
conditional_sync_wahoo_key |
— | Same, for Wahoo workouts |
Conditional sync
By default every activity found in the sync window is synced. Set a keyword and that source flips to the opposite default — nothing is synced unless you mark it:
[[users]]
name = "alice"
source = "both"
conditional_sync_garmin_key = "sync"
conditional_sync_wahoo_key = "sync"
Name an activity Feierabendrunde sync in Garmin Connect or the Wahoo app and it
is synced; leave it as Feierabendrunde and it is skipped. The two keys are
independent — set only one to gate a single source, or give each a different
keyword. Both accept env: references
(conditional_sync_wahoo_key = "env:CONDITIONAL_SYNC_WAHOO_KEY").
Matching is a case-insensitive substring test, so Ride SYNC, sync ride
and resync all match. On Wahoo, either name matches: the title the head unit
recorded and the one you type in the app are two different fields upstream (see
below), and marking the workout in either place counts. On a match the marker is
dropped from the name before
anything is stored, so the Mastodon post and CalDAV event read
Feierabendrunde — the whole word containing the keyword goes, not just the
letters (resync test → test).
Skipping is total. An unmarked activity costs one string comparison: no API detail call, no GPX/FIT download, no map or insights, no database row, nothing posted. It leaves no trace at all, which is what makes the next point work.
Renaming works in both directions. The keyword is re-checked every cycle against the name as it currently stands upstream, never against the stored one:
- Mark an activity you originally left alone → it syncs in full on the next run.
- Unmark one whose post has not gone out yet (indoor rides defer one cycle, and failed posts are retried) → the post is called off.
- Rename a marked activity while its post is still pending → the new name is what gets posted.
Already-posted activities are left alone; unmarking one does not delete it or retract the post.
Wahoo keeps two names, which is worth knowing when a marked workout does not
sync. workout["name"] is the title the ELEMNT recorded — Radfahren, KICKR —
and it never changes afterwards. Renaming in the Wahoo app writes a separate
field, workout_summary["name"]. Both are checked for the keyword, and the
summary's name — the one you can actually edit — is what gets stored and posted.
Three consequences:
- A rename has to reach the Wahoo cloud before the sync can see it. Check
workout_summary.nameonGET /v1/workouts/{id}if in doubt; if it still shows the device title, the app has not synced the edit yet. - Renaming the Garmin Connect copy of a workout that
wahoo_sync_to_garminuploaded has no effect on the Wahoo gate. That copy is downstream of the decision being made here. - Renaming does not bump the workout's
updated_at, so a rename cannot be spotted by anupdated_afterquery. It is only seen because a keyword makes every run rescan the wholelookback_dayswindow (below). Don't be tempted to narrow that window back down.
workout_summary["edited"] looks like it should mark a rename and does not —
it flags an edit to the workout data, such as a trim, and is set on roughly
half the workouts in a long-standing account regardless of their names.
The lookback window is the deadline. With a keyword set, each run rescans the
full lookback_days window (default 30) instead of resuming from the last synced
activity — that is what lets a skipped activity be reconsidered at all, since it
left no row to resume from. Mark an activity older than that and it stays gone.
Raise lookback_days in [sync] if you want a longer grace period.
Data directory layout
~/data/garminnostra/
├── garmin_nostra.db # SQLite database
├── gpx/
│ ├── alice/
│ │ └── 12345678.gpx
│ └── bob/
│ └── 87654321.gpx
├── fit/
│ ├── alice/
│ │ └── 12345678.fit
│ └── bob/
│ └── 87654321.fit
└── maps/
├── alice/
│ └── 12345678.png
└── bob/
└── 87654321.png
~/data/garmin-tokens/
├── alice/ # Garmin OAuth tokens
└── bob/
Tokens live in a separate directory, ~/data/garmin-tokens, mounted into the container at /tokens rather than nested under /data. This lets anything else that needs read access to the activity data — e.g. a reporting tool, or an MCP server exposing it to an LLM — bind-mount all of ~/data/garminnostra without ever being able to see a Garmin/Wahoo OAuth token. Nesting the tokens directory under /data instead would defeat that: Docker creates a mountpoint directory for any nested bind mount on the host side of its parent bind mount, so a tokens/ entry (empty, but present) would always reappear inside ~/data/garminnostra on every container start.
Both data directories are bind-mounted from the host (~/data/garminnostra and ~/data/garmin-tokens by default — change the left side of the corresponding volume in docker-compose.yml to relocate either one). All files survive container rebuilds.
Database schema
users
| Column | Type | Description |
|---|---|---|
id |
INTEGER PK | |
name |
TEXT UNIQUE | Config name |
garmin_username |
TEXT | |
mastodon_handle |
TEXT | |
caldav_enabled |
INTEGER | 0/1 |
created_at |
TEXT | ISO-8601 UTC |
activities
One row per activity per user. Key columns:
| Column | Type | Description |
|---|---|---|
user_id |
INTEGER FK | |
garmin_activity_id |
TEXT | Garmin's ID |
activity_type |
TEXT | running, cycling, … |
start_time_utc |
TEXT | ISO-8601 UTC |
duration_s |
REAL | Total duration (seconds) |
distance_m |
REAL | Distance (metres) |
elevation_gain_m |
REAL | Positive elevation (metres) |
avg_hr |
INTEGER | Avg heart rate (bpm) |
avg_power_w |
REAL | Avg power (watts) |
normalized_power_w |
REAL | NP (watts) |
avg_speed_ms |
REAL | Avg speed (m/s) |
training_stress_score |
REAL | TSS |
vo2max_estimate |
REAL | |
calories |
INTEGER | |
raw_json |
TEXT | Full Garmin API payload |
gpx_path |
TEXT | Path to saved GPX file |
fit_path |
TEXT | Path to saved FIT file |
source |
TEXT | Origin of the record: GarminNoStra or WahooNoStra |
wahoo_synced_to_garmin |
INTEGER | 0/1 — set when a Wahoo activity has been uploaded to Garmin Connect |
caldav_pushed |
INTEGER | 0/1 |
mastodon_posted |
INTEGER | 0/1 |
Full column list: see src/storage.py.
kudos_sent
Deduplication log for KudosMachine — one row per (status, fav-giver) pair.
| Column | Type | Description |
|---|---|---|
status_id |
TEXT PK | Mastodon status ID of the activity post |
account_id |
TEXT PK | Mastodon account ID of the fav-giver |
sent_at |
TEXT | ISO-8601 UTC timestamp |
wahoo_skipped
Permanently inaccessible Wahoo workouts (401 Unauthorized). Checked before making API calls so skipped workouts produce no network traffic or log noise on subsequent runs.
| Column | Type | Description |
|---|---|---|
user_id |
INTEGER PK | FK to users.id |
wahoo_id |
TEXT PK | Wahoo workout ID |
reason |
TEXT | Why it was skipped (e.g. 401 Unauthorized) |
skipped_at |
TEXT | ISO-8601 UTC timestamp |
sync_runs
Audit log — one row per sync attempt per user.
activity_track_signatures
Precomputed GPS-track "signature" for route/overlap comparison (e.g. by
nostra-mcp's find_similar_activities tool). One row per activity that has
usable track data — computed automatically at sync time (see below), no
configuration needed. Deliberately a separate table rather than columns on
activities: it is a derived, versioned artifact tied to cell_size_m, not
a scalar metric, and most callers reading an activity have no use for it.
| Column | Type | Description |
|---|---|---|
activity_id |
INTEGER PK | FK to activities.id |
cell_size_m |
REAL | Grid cell size (metres) used to compute cells — currently 35.0 |
cells |
TEXT | Sorted, comma-joined "x:y" grid-cell ids the track passes through |
point_count |
INTEGER | Raw GPX trackpoints parsed; a low number flags a suspiciously short track |
source_format |
TEXT | gpx (Garmin's native file) or fit (Wahoo — see below) |
computed_at |
TEXT | ISO-8601 UTC timestamp |
How the signature works: each trackpoint is projected with a simple
equirectangular transform and quantized to a cell_size_m grid cell; the
signature is the set of unique cells the track touches. Comparing two
signatures is then a Jaccard similarity (|A∩B| / |A∪B|) on two small sets
of "x:y" strings — order-independent (a loop ridden in either direction
scores the same) and naturally tolerant of partial overlap (an early
turnaround just shrinks the intersection). See src/track_signature.py.
Garmin vs. Wahoo data source: Garmin activities have a native .gpx
file on disk (gpx_path), read directly. Wahoo activities only ever persist
.fit (fit_path) — GPX exists only transiently in memory (derived via
map_render.fit_to_gpx() for map/elevation-profile rendering) and is never
saved to disk, so the signature is computed from that same in-memory GPX
before it's discarded. Indoor Wahoo rides (indoor_cycling) have no GPS
track to convert and correctly get no signature.
Backfilling existing activities: scripts/backfill_track_cells.py
computes signatures for activities that predate this feature (or after
changing cell_size_m, which invalidates every existing signature).
Idempotent — only processes activities with no signature row yet, so a
rerun after a first pass touches 0 rows — and never modifies activities,
only adds rows to activity_track_signatures.
# From the repo root, run inside the container image (has gpxpy/fitparse):
docker compose run --rm --entrypoint python3 garmin-nostra \
/app/scripts/backfill_track_cells.py /app/config.toml --dry-run # report only, writes nothing
docker compose run --rm --entrypoint python3 garmin-nostra \
/app/scripts/backfill_track_cells.py /app/config.toml --limit 20 # small real batch
docker compose run --rm --entrypoint python3 garmin-nostra \
/app/scripts/backfill_track_cells.py /app/config.toml # full run
Note the
--entrypoint python3override. This image'sENTRYPOINTis/entrypoint.sh, which runs the normal sync loop unconditionally and ignores any command passed via a plaindocker compose run garmin-nostra ...— the arguments get silently appended to the entrypoint script rather than replacing it, starting a second, redundant sync loop instead of the script. Always pass--entrypoint python3when running a one-off script this way.
A small number of older rows may have gpx_path/fit_path stored as a host
absolute path (/home/<user>/data/garminnostra/gpx/...) rather than the
container path (/data/gpx/...) every other row uses — a pre-existing data
inconsistency, not something this feature introduced or corrects. The
backfill script tolerates it: if the stored path isn't readable, it falls
back to reconstructing <gpx_dir>/<user-dir>/<filename> from the path's
last two components before giving up. (scripts/_backfill_common.py holds
this path-resolution/gzip-decompression logic shared with
backfill_insights.py below.)
activity_insights
Precomputed per-kilometre pace/heart-rate/cadence splits (e.g. for
nostra-mcp's get_activity_insights tool). One row per activity with at
least one full km — computed automatically at sync time, no configuration
needed. Whole-activity aggregates (avg_hr, avg_cadence, avg_power_w,
etc.) already exist as columns on activities; this table holds only the
per-split breakdown and two derived cross-split flags, not a copy of what's
already there.
| Column | Type | Description |
|---|---|---|
activity_id |
INTEGER PK | FK to activities.id |
schema_version |
INTEGER | Bumped when the computation logic changes shape, to force reprocessing |
source_format |
TEXT | gpx (Garmin's native file, HR/cadence via its TrackPointExtension) or fit (parsed directly from FIT record messages) |
splits_json |
TEXT | {unit: "km", splits: [...], partial_last_split_m} — see below |
hr_drift_pct |
REAL | Second-half vs. first-half average HR, percent change; NULL if no HR data |
negative_split |
INTEGER | 0/1/NULL — second half faster than the first; NULL if too few splits to say |
has_hr / has_cadence / has_power |
INTEGER | Whether the source device recorded that metric at all |
computed_at |
TEXT | ISO-8601 UTC timestamp |
splits_json shape, one object per completed kilometre:
{
"unit": "km",
"elev_source": "barometric",
"splits": [
{"index": 1, "distance_m": 1001.4, "duration_s": 309.0, "pace_s_per_km": 308.6,
"avg_hr": 133, "avg_cadence": 92, "avg_power_w": null,
"elev_gain_m": 6.4, "elev_loss_m": 1.2, "avg_grade_pct": 0.5,
"max_grade_pct": 3.1, "profile": "flat"}
],
"partial_last_split_m": 633.1
}
Every metric field is independently nullable — a device with no paired
HR/cadence/power sensor gets null on every split, not a dropped row; a
device with no altitude gets null on every terrain field.
Terrain fields (schema v2). Alongside pace/HR/cadence, each split carries
the shape of the ground it covered, derived from the elevation series:
elev_gain_m/elev_loss_m, avg_grade_pct (net, signed), max_grade_pct
(steepest sustained climb — measured over a rolling ~100 m window so one
noisy altitude sample can't invent a cliff), and a coarse profile label
(climb / descent / rolling / flat). The top-level elev_source is
"barometric" when the FIT stream carried enhanced_altitude, else
"unknown" (all GPX, and FIT without an altimeter), so a consumer knows how
far to trust the grades. Real surface type (road vs. gravel vs.
singletrail) is deliberately not inferred — it isn't in the sensor stream
and guessing it from motion alone is unreliable; that would need map-matching
against OSM data, a separate future effort.
Garmin vs. Wahoo data source — a genuinely different path from track
signatures, not just relabeled. Garmin's native GPX embeds heart-rate and
cadence via a TrackPointExtension (element names matched by local name,
e.g. hr/cad, not by namespace URI — different exporters use different
schema URIs for the same convention), so it's parsed straight from GPX.
Wahoo activities (and old FIT-only Garmin ones) have no native GPX, and
map_render.fit_to_gpx() — built for map/elevation rendering — only
carries lat/lon/ele/time through, silently dropping heart-rate/cadence/
power. So insights parse FIT record messages directly for those, and
deliberately never go through fit_to_gpx(). See src/insights.py.
Backfilling existing activities: scripts/backfill_insights.py, same
shape as backfill_track_cells.py above (idempotent, --dry-run/--limit,
never modifies activities) — see that section for the exact invocation
pattern (same --entrypoint python3 override applies) and the host-path
quirk it also tolerates.
Useful SQL queries
-- Total km per user this year
SELECT u.name, ROUND(SUM(a.distance_m) / 1000.0, 1) AS km
FROM activities a JOIN users u ON a.user_id = u.id
WHERE a.start_time_utc >= '2025-01-01'
GROUP BY u.name;
-- Monthly running km for alice
SELECT SUBSTR(start_time_utc, 1, 7) AS month,
ROUND(SUM(distance_m) / 1000.0, 1) AS km,
COUNT(*) AS runs
FROM activities
WHERE user_id = (SELECT id FROM users WHERE name = 'alice')
AND activity_type = 'running'
GROUP BY month ORDER BY month;
-- Average pace trend (running) for alice
SELECT SUBSTR(start_time_utc, 1, 7) AS month,
ROUND(AVG(duration_s / (distance_m / 1000.0)), 0) AS avg_pace_s_per_km
FROM activities
WHERE user_id = (SELECT id FROM users WHERE name = 'alice')
AND activity_type = 'running' AND distance_m > 0
GROUP BY month ORDER BY month;
-- Activities with pending Mastodon post
SELECT u.name, a.garmin_activity_id, a.activity_type, a.start_time_utc
FROM activities a JOIN users u ON a.user_id = u.id
WHERE a.mastodon_posted = 0
ORDER BY a.start_time_utc;
Development & testing
Install dev dependencies
pip install -r requirements-dev.txt
Run the test suite
.venv/bin/python3 -m pytest tests/ -q
All tests are offline — no Garmin, Wahoo, Mastodon, or CalDAV credentials are needed. External services are replaced by mocks.
Expected output:
118 passed in ~27s
Test structure
| File | What it covers |
|---|---|
tests/test_format.py |
German formatting helpers and build_mastodon_message |
tests/test_storage.py |
ActivityStore — save/get, deduplication, power backfill, cross-source suppression, migration resilience, source-scoped sync window |
tests/test_wahoo_map.py |
map_wahoo_activity, Wahoo type mapping, safe-conversion helpers |
tests/test_sync_logic.py |
Full sync flow with mocked API clients (Garmin, Wahoo, Mastodon, CalDAV) |
tests/test_insights.py |
Per-km split computation from GPX and FIT, trend flags, non-positive-duration edges |
tests/test_track_signature.py |
Grid-cell track signatures, Jaccard overlap, malformed-input handling |
tests/test_map_render.py |
FIT→GPX conversion and map/elevation rendering |
tests/test_mastodon_bot.py |
Media upload/retry and post assembly |
Fixtures in tests/fixtures/ are anonymized JSON files — no real GPS coordinates, account names, or activity IDs.
Scenarios covered by the tests
- Running — distance, pace, HR stored and formatted correctly
- Outdoor cycling — distance, speed, elevation
- Indoor cycling — initial save without power (
avg_power_w = NULL), then filled bybackfill_activity_metricson the second sync cycle - No double-insert —
INSERT OR IGNOREverified for both Garmin and Wahoo activities - Cross-source dedup — an overlapping Garmin activity is persisted as
suppressed(not silently dropped) and not posted, regardless of which source arrived first - Source-scoped sync window — under
source = "both", the Garmin and Wahoo sync windows are tracked independently so neither drags the other forward - Wahoo→Garmin bridge — FIT file uploaded on first sync, duplicate error handled gracefully, no retry after success
- Retry re-attaches media — a Wahoo post that failed once re-attaches the map rendered on the first run instead of going text-only
- Insights from FIT — per-km splits parsed directly from FIT
recordmessages (the Wahoo path), including graceful handling of missing sensors and non-positive split durations - Migration resilience — a row with malformed
raw_jsondoes not crash store construction (json_extract backfills arejson_valid-guarded) - 10-minute gate — activities younger than 10 minutes are skipped until the next cycle
- Indoor cycling deferral — integrations (Mastodon, CalDAV) deferred to next cycle so Garmin finishes computing power
- Conditional sync — keyword matching and word-wise stripping; an unmarked activity leaves no row and triggers no download or API detail call; renaming into the keyword syncs it on the next cycle, renaming out of it halts a still-pending post, and renaming a pending one posts the new name
- Wahoo's two name fields — an app rename (
workout_summary.name) satisfies the keyword gate and is what gets stored, a marker on the device title counts too, the misleadingeditedflag is not consulted in either direction, and the KICKR indoor heuristic survives a rename
Run only specific tests
# One module
.venv/bin/python3 -m pytest tests/test_storage.py -v
# One test by name
.venv/bin/python3 -m pytest tests/test_storage.py::test_backfill_fills_null_power -v
# Only fast unit tests (no mocked network overhead)
.venv/bin/python3 -m pytest tests/test_format.py tests/test_wahoo_map.py -v
Integration tests (requires live credentials)
Integration tests that hit the real Garmin or Wahoo APIs are not included in the default suite. Mark them with @pytest.mark.integration and run with:
.venv/bin/python3 -m pytest tests/ -m integration
Module overview
| File | Role |
|---|---|
src/sync.py |
Main entry point; iterates users, orchestrates pipeline |
src/garmin.py |
Garmin Connect client with per-user token caching |
src/wahoo.py |
Wahoo Cloud API client with OAuth 2.0 token refresh |
src/wahoo_auth.py |
One-time OAuth bootstrap helper to obtain Wahoo refresh tokens |
src/bootstrap_auth.py |
One-time helper to bootstrap Garmin OAuth tokens on the host |
src/storage.py |
SQLite store — users, activities, kudos deduplication, sync audit log |
src/format.py |
German formatting: dates, numbers, pace, message builder |
src/map_render.py |
FIT→GPX + GPX → PNG map/elevation profile via staticmap (OSM tiles) & Pillow |
src/track_signature.py |
Precomputes a grid-cell GPS-track signature for route-similarity comparison |
src/insights.py |
Precomputes per-kilometre pace/HR/cadence/power splits and trend flags |
src/mastodon_bot.py |
Bot that posts mentions with optional map attachment (public, unlisted, or direct DM) |
src/kudos_machine.py |
Polls activity posts for new favourites and sends kudos replies |
src/caldav_push.py |
Builds VEVENT and pushes to Nextcloud CalDAV |
Requirements
- Docker & Docker Compose
- A Mastodon bot account with
read:statuses write:statuses write:mediascopes - Garmin Connect credentials per Garmin user
- (optional) Wahoo developer app credentials per Wahoo user (register at developers.wahooligan.com)
- (optional) A Nextcloud CalDAV calendar
Python dependencies (installed inside the container):
garminconnect caldav icalendar Mastodon.py
gpxpy fitparse staticmap Pillow requests
(See requirements.txt for the authoritative, version-pinned list.)
Troubleshooting
Garmin authentication fails (401 / 429) Garmin's SSO is protected by Cloudflare, which can block programmatic logins. Run the browser-based bootstrap to obtain tokens — see Bootstrap Garmin tokens above.
If you already have working tokens from a previous installation, you can copy them directly:
cp ~/old/path/tokens/<name>/garmin_tokens.json ~/data/garmin-tokens/<name>/garmin_tokens.json
After this, the next sync will load the saved tokens and skip the credential login entirely.
Garmin MFA / 2FA The bootstrap script handles MFA — complete any MFA challenge in the browser window that opens, then capture the service ticket as described in the bootstrap steps.
Map not attached
The staticmap library fetches tiles from tile.openstreetmap.org. Make sure the container has outbound internet access. Indoor activities without GPS will not produce a map.
Mastodon post not visible
The default visibility is direct (DM) — the post only appears in the mentioned user's DM/notifications timeline, not publicly. Set mastodon_public = "listed" for unlisted posts (visible to followers and on the bot's profile, but not on the public timeline), or mastodon_public = true for fully public posts. On some instances, mentions from unfollowed accounts land in filtered notifications.
To CC additional accounts on a DM post, add mastodon_add_mention = "@other@instance" to the user block — all mentioned handles receive the DM.
Wahoo authentication fails Re-run the OAuth bootstrap to obtain a fresh refresh token — see Wahoo setup above for the full procedure. Wahoo refresh tokens expire after 60 days of inactivity.
Wahoo activities have no map image Wahoo does not provide GPX files. Map rendering is currently only available for Garmin activities. FIT files are downloaded and stored.
CalDAV calendar not found The calendar must already exist in Nextcloud. The error message lists available calendar names.
Migrating from older versions
Non-root container (March 2026)
The container no longer runs as root. Instead it uses a non-root user appuser with UID 1000 / GID 1000. This improves security but requires a one-time ownership fix on the data directory:
sudo chown -R 1000:1000 ~/data/garminnostra
Why? Older versions ran as root inside Docker, so all files (GPX, FIT, maps, tokens, log, DB) were created with
root:rootownership. The new non-root container cannot write to root-owned files.
You can verify the result with:
# Should return nothing (= no files left with wrong ownership)
find ~/data/garminnostra -not -user 1000 -ls
If your host user has a UID other than 1000, either adjust the chown to match the container's UID (1000), or override the container's user in docker-compose.yml:
services:
garmin-nostra:
user: "1001:1001" # replace with your host UID:GID
Then chown the data directory to match that UID instead.
Cron replaced by sleep loop (March 2026)
The container no longer installs or uses cron. Sync scheduling is now a simple shell loop (sleep between runs). This means:
- No behaviour change for typical use — sync still runs at the configured
interval_minutes. - The interval is measured from end-of-sync to start-of-next-sync, not wall-clock aligned. For a 60-minute interval with a 2-minute sync, the next run starts at minute 62 instead of exactly on the hour. This is negligible in practice.
- Logs go directly to stdout (no
/proc/1/fdredirects), which is cleaner fordocker logs.
No action needed — just rebuild:
docker compose up -d --build
Garmin API session reuse (March 2026)
GPX and FIT downloads now reuse the already-authenticated Garmin session instead of creating a fresh client (with a full OAuth token exchange + profile fetch) for every single download. For a sync with N new activities this eliminates 2×N redundant authentication round-trips, cutting per-activity overhead by ~2 seconds each.
No action needed — the change is internal to src/garmin.py. Per-download timeouts via ThreadPoolExecutor are still in place.
Environment variable secrets (March 2026)
Config values can now reference environment variables with the env: prefix. This is opt-in — existing plaintext configs work unchanged.
Before (plaintext in config.toml):
mastodon_access_token = "abc123secrettoken"
After (secret in environment, reference in config):
mastodon_access_token = "env:MASTODON_TOKEN"
# docker-compose.yml
environment:
- MASTODON_TOKEN=abc123secrettoken
This avoids storing secrets in the config file and works with Docker secrets, .env files, or CI/CD variable injection.
Wahoo support (March 2026)
Users can now sync activities from Wahoo instead of Garmin Connect. This is opt-in — existing Garmin-only configurations work unchanged without any modifications.
See Wahoo setup for the full setup procedure (developer app registration, OAuth bootstrap, config).
The database schema is extended automatically (two new columns added on first run). No manual migration needed.