No description
Find a file
Lars Hoeyer 6f11eede6b init
2026-08-25 20:28:03 +00:00
backups init 2026-08-25 20:28:03 +00:00
config init 2026-08-25 20:28:03 +00:00
fastapi init 2026-08-25 20:28:03 +00:00
backup_db.sh init 2026-08-25 20:28:03 +00:00
Daniela_com.samsung.health.weight.20251110133813.csv init 2026-08-25 20:28:03 +00:00
docker-compose.yml init 2026-08-25 20:28:03 +00:00
gewicht.csv init 2026-08-25 20:28:03 +00:00
import_samsung_scale_csv.sh init 2026-08-25 20:28:03 +00:00
import_scale_csv.sh init 2026-08-25 20:28:03 +00:00
init.sh init 2026-08-25 20:28:03 +00:00
Lars_com.samsung.health.weight.20251204225581.csv init 2026-08-25 20:28:03 +00:00
preview_samsung_scale_import.sh init 2026-08-25 20:28:03 +00:00
preview_scale_csv_import.sh init 2026-08-25 20:28:03 +00:00
README.md init 2026-08-25 20:28:03 +00:00
restore_db.sh init 2026-08-25 20:28:03 +00:00

Garmin Log

A single Docker Compose project that downloads Garmin Connect data for multiple users, stores it in PostgreSQL, and provides provisioned Grafana dashboards for individual and comparative analysis.

Components

  • api: FastAPI application and scheduled Garmin Connect importer
  • postgres: normalized statistics plus complete raw JSON responses
  • grafana: provisioned PostgreSQL datasource and dashboards

Writable container state is kept in data/api, data/postgres, and data/grafana. Read-only application configuration is kept in config.

The importer stores activity summaries, activity details, splits, weather, exercise sets, daily summaries, sleep, heart rate, stress, body battery, SpO2, respiration, HRV, training readiness, weight, user profile, and device data. Unsupported endpoints or data absent from a user's Garmin account are skipped without failing the whole sync.

Garmin Connect does not provide a supported public API for complete personal data export. This project uses the unofficial garminconnect Python client. Endpoint behavior can change, and Garmin may require reauthentication or apply rate limits.

Setup

Run the initialization script:

./init.sh

It creates .env and config/users.yml when absent, creates the writable directories with the required container ownership, creates .venv, and installs all FastAPI runtime and test dependencies.

Then:

  1. Set strong PostgreSQL and Grafana passwords in .env.

  2. Add each Garmin account and its credentials to config/users.yml:

    users:
      - id: alice
        display_name: Alice
        email: alice@example.com
        password: replace-with-real-password
        enabled: true
    

    The stable id is used in database rows, token paths, and Grafana filters; changing it creates a new logical user.

  3. Start the project:

    docker compose up --build -d
    
  4. Open:

The container IDs used by init.sh are API 65534:65534, PostgreSQL Alpine 70:70, and Grafana 472:0. config/users.yml is owner-readable and group-readable by the API container (640); .env is owner-only (600).

The first import runs on startup. By default it imports all available activity pages and 365 days of daily health data. Historical activity detail is fetched once and retained. Later syncs stop after reaching a page of known activities and refresh the most recent 14 days of daily data. Garmin authentication tokens are retained in data/api so scheduled imports do not need a fresh password login every time.

Garmin tokens are stored per user under data/api/tokens/<user-id> and survive API container recreation and host restarts because data/api is a bind mount. Credentials remain in config/users.yml; they are consulted only when no valid token can be loaded or Garmin invalidates an existing token.

Configuration

Key .env values:

Variable Default Purpose
GARMIN_INITIAL_DAYS 365 Daily history fetched until the first successful sync
GARMIN_SYNC_DAYS 14 Daily lookback after the first successful import
GARMIN_SYNC_SCHEDULE 0 */6 * * * Cron schedule
SYNC_ON_STARTUP true Run an import when API starts
TZ Europe/Berlin Scheduler and container timezone
API_PORT 8000 Host API port
GRAFANA_PORT 3000 Host Grafana port

The initial import can be large. To reduce API load, set GARMIN_INITIAL_DAYS=30. Activity history remains exhaustive up to the safety limit of 10,000 activities (100 pages of 100).

Multi-user comparison

The Garmin Overview dashboard has a multi-select Users variable. Choose one, several, or all users to compare activity distance and resting heart rate. Each row in the Activities table has a Details link to the same activity detail dashboard used for shared-run comparisons.

The Overview also includes raw intraday heart rate, raw HRV, daily average HRV, and a one-row-per-night sleep table. Sleep rows include timing, score, phase percentages, SpO₂, respiration, stress, restlessness, awakenings, and sleep need where Garmin provides them. Sleep phases are shown as a 100%-stacked horizontal bar inside each sleep-table row. Click Details to open the nightly sleep dashboard with a sleep-stage timeline, phase-duration chart, heart rate, HRV, and the complete normalized night summary.

The Shared Runs dashboard pairs runs from selected users when:

  • start times differ by no more than 15 minutes, and
  • recorded distances differ by no more than 0.5 km.

Both thresholds are adjustable dashboard variables. This is deliberately an inference because Garmin activities do not consistently expose participant relationships.

Paces are always shown as min:sec /km, including graph axes, tooltips, legends, and tables. Click Details on a shared-run or Overview activity row to open an individual detail view or comparison with pace, heart rate, GPS tracks, elevation, cadence, ground contact time, vertical ratio, and summary statistics. The detail view automatically uses the time range from the earliest sample to the latest sample across both runners. Activity sample data is extracted from Garmin's stored activity-detail payloads; run a sync after upgrading to backfill samples for details that were downloaded previously.

The Garmin Sync Status dashboard shows running imports, the latest successful import, recent failures, and up to 500 sync-history rows. Use the Trigger sync for all users link to schedule every configured user. Set the API URL dashboard field if the API is not available at http://localhost:8000. When a sync reports mfa_required, select MFA user, enter the numeric MFA code, and use the dashboard's Submit MFA code link. The accepted code refreshes the persisted token and retries that user's sync. The dashboard refreshes every 10 seconds.

API

  • GET /health
  • GET /api/users
  • POST /api/sync
  • POST /api/sync/{user_id}?days=30
  • GET /api/auth/{user_id}/mfa
  • POST /api/auth/mfa
  • POST /api/auth/{user_id}/mfa
  • GET /api/activities?user_id=alice&user_id=bob
  • GET /api/sync-runs

The API is not authenticated. Do not expose port 8000 publicly without putting it behind an authenticated reverse proxy.

Garmin MFA

When Garmin requires MFA, the sync run ends with status mfa_required. Check the status and submit the numeric code received from Garmin:

curl http://localhost:8000/api/auth/alice/mfa

curl -X POST http://localhost:8000/api/auth/alice/mfa \
  -H 'Content-Type: application/json' \
  -d '{"code":"123456"}'

In FastAPI Swagger at http://localhost:8000/docs, use POST /api/auth/mfa and enter both values:

{
  "user_id": "alice",
  "code": "123456"
}

Submitting a valid code stores refreshed Garmin tokens in data/api/tokens and automatically schedules the user's sync again. Scheduled imports then reuse those tokens without asking for MFA until Garmin invalidates them.

The pending MFA challenge is held in API memory because it contains an active Garmin login session. If the API container restarts while waiting for the code, trigger another user sync to create a new challenge:

curl -X POST http://localhost:8000/api/sync/alice

Operations

View importer status:

curl http://localhost:8000/api/sync-runs
docker compose logs -f api

Trigger a user import:

curl -X POST 'http://localhost:8000/api/sync/alice?days=30'

Stop containers without deleting data:

docker compose down

Delete all PostgreSQL and Grafana data:

docker compose down -v