No description
  • Python 99.5%
  • Shell 0.3%
  • Dockerfile 0.2%
Find a file
Vinzenz Greger 59588fe600 Wahoo: the summary name is the name, not just when "edited"
Follow-up to e265da9, which read workout_summary["name"] only when the summary
carried edited=True. That flag does not mean what it looks like. Across the 613
workouts of the account this was traced on it is set on 274 of them, nearly all
with both names identical, so it marks an edit to the workout *data* -- a trim
or a crop -- and has nothing to do with the title. Renames arrive with
edited=None as often as not: "Bad Krozingen #sv", typed into the app minutes
before this commit, came through with the flag unset and was skipped by the
very fix meant to catch it.

So take the summary's name whenever it has one, and fall back to the device
title otherwise -- that fallback is load-bearing, as 55 of those 613 workouts
have a summary with no name at all. Checked against every workout in the
account: the rule changes nothing for the identical-name majority, upgrades a
generic "ANDERE" to the "Indoor-Radfahren" its summary already carried, and
loses one 2020 workout's "Test Drive" to a blander "Cycling". Fine trade for
renames working.

Documents two things worth knowing while the data is fresh: a rename does not
bump the workout's updated_at, so it can never be found by an updated_after
query and is only seen because a keyword makes each run rescan the whole
lookback window; and the KICKR indoor heuristic deliberately keeps reading the
device title, which is the field a rename leaves alone.

Verified end to end against the live account after deploying: 9 found /
1 processed, the marked workout stored as "[Wahoo] Bad Krozingen" with the
marker stripped, the other eight untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:21:03 +02:00
scripts Add scripts/purge_activities.py: walk back a sync that should not have run 2026-07-26 13:51:16 +02:00
src Wahoo: the summary name is the name, not just when "edited" 2026-07-26 14:21:03 +02:00
tests Wahoo: the summary name is the name, not just when "edited" 2026-07-26 14:21:03 +02:00
.gitignore Harden .gitignore for secrets; remove stray tracked file 2026-07-22 07:11:36 +02:00
claude.md Add test suite, fix power/cadence field mapping, deduplicate speed types 2026-04-04 15:50:52 +02:00
config.toml.example Add opt-in conditional sync: only sync activities marked by keyword 2026-07-26 13:16:57 +02:00
docker-compose.yml Move OAuth tokens to a separate directory from the rest of the data 2026-07-21 08:11:20 +02:00
Dockerfile Add GPS-track signatures for route-similarity comparison 2026-07-21 14:17:55 +02:00
entrypoint.sh Harden container, reuse Garmin session, add env-var secrets support 2026-03-19 20:56:47 +01:00
Garmin-Connect_paywright-bypass.md updated bootstrap procedure as stand-alone 2026-04-07 21:41:04 +02:00
garminnostra_avatar.png power will now only appear for cycling/speed activity types 2026-04-09 12:04:35 +02:00
LICENSE Add Apache-2.0 license and OSM attribution to map images 2026-03-12 08:48:12 +01:00
pytest.ini Add test suite, fix power/cadence field mapping, deduplicate speed types 2026-04-04 15:50:52 +02:00
README.md Wahoo: the summary name is the name, not just when "edited" 2026-07-26 14:21:03 +02:00
requirements-dev.txt Add test suite, fix power/cadence field mapping, deduplicate speed types 2026-04-04 15:50:52 +02:00
requirements.txt Render map for Wahoo activities using fit to gpx converter 2026-04-08 20:45:25 +02:00

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:

  1. Press F12 to open DevTools, go to the Console tab
  2. In Firefox, type allow pasting and press Enter first (ignore the error)
  3. Paste and run: window.addEventListener('beforeunload', function(e) { e.preventDefault(); e.returnValue = ''; });
  4. Log in normally (complete any CAPTCHA or MFA)
  5. A "Leave this page?" dialog appears — click Stay on Page
  6. Switch to the Network tab, filter by login
  7. Click the /portal/api/login request, go to the Response tab
  8. Copy the serviceTicketId value (ST-xxxxx)
  9. 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

  1. Go to developers.wahooligan.com/cloud and sign in with your Wahoo account.

  2. Create a new application with the following settings:

    Field Value
    Redirect URI https://localhost
    Environment Sandbox (switch to Production once confirmed working)
    Confidential Yes
    Webhook Leave blank — not needed (garmin-nostra polls the API)
  3. 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:

  1. Print an authorization URL and open it in your browser.
  2. After you authorize, the browser redirects to https://localhost?code=… — the page will not load (this is expected).
  3. Copy the code parameter from the browser's address bar and paste it back into the script.
  4. 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, and log_file must start with /data/ — the container's access to that data comes from the volume mount ~/data/garminnostra → /data. token_dir is 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 testtest).

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.name on GET /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_garmin uploaded 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 an updated_after query. It is only seen because a keyword makes every run rescan the whole lookback_days window (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| / |AB|) 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 python3 override. This image's ENTRYPOINT is /entrypoint.sh, which runs the normal sync loop unconditionally and ignores any command passed via a plain docker 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 python3 when 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 by backfill_activity_metrics on the second sync cycle
  • No double-insertINSERT OR IGNORE verified 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 record messages (the Wahoo path), including graceful handling of missing sensors and non-positive split durations
  • Migration resilience — a row with malformed raw_json does not crash store construction (json_extract backfills are json_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 misleading edited flag 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:media scopes
  • 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:root ownership. 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/fd redirects), which is cleaner for docker 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.