Skip to main content
Glama
README.md
# garmin-sync

Pulls your Garmin Connect data into a local SQLite database on a schedule, and
serves it back to an AI assistant over MCP, read-only.

Your health history is otherwise only visible through Garmin's own app, one
screen at a time. This puts it in a file you own, in a shape you can query.

```bash
mkdir -p data tokens logs                                    # Docker would make these root-owned
cp .env.example .env                                         # your Garmin login
docker compose run --rm garmin-sync python first_login.py    # once; handles 2FA
docker compose run --rm garmin-sync                          # pull
docker compose up -d garmin-mcp                              # optional query server
```

The shipped `.env.example` backfills from the start of 2025. Widen
`BACKFILL_START` once you have seen a run work — every extra year is another
~5,800 requests to an API that rate-limits unofficial clients.

`compose.yaml` needs no editing. Everything is relative to the folder it sits
in, so a clone runs where it stands.

## What you end up with

```
Garmin Connect
      │  python-garminconnect (mobile SSO + curl_cffi TLS impersonation)
      ▼
   sync.py ──► garmin.db     raw JSON  +  normalised tables
                    │
                    └──────► mcp_server.py  (read-only, for an assistant)
```

**Two layers, on purpose.** `raw_records` keeps every API response verbatim, so
a Garmin field change never loses data you already have. On top of that sit
typed tables — `activities`, `daily_summary`, `sleep`, `hrv`, `body_battery`,
`training_readiness`, `weight` — for querying. If a normaliser breaks on a
changed payload the raw row is still stored; the two are independent by design.

**Ten of the sixteen collectors are archived but not yet queryable.** The
second layer was only ever built for six of them:

| | |
|---|---|
| **Queryable** — typed tables, visible to SQL and to the MCP server | `stats`, `sleep`, `hrv`, `body_battery`, `training_readiness`, `weigh_ins` |
| **Archived only** — captured as raw JSON, no typed table yet | `all_day_stress`, `steps`, `spo2`, `respiration`, `intensity_minutes`, `floors`, `resting_hr`, `max_metrics`, `training_status`, `hydration` |

Nothing is lost — every one of those is fetched daily and stored complete, and
you can dig it out of `raw_records` with SQLite's JSON functions. But your
assistant cannot see it, and neither can a plain query, until someone writes
the normaliser. Adding one is a small, self-contained job in `collectors.py`:
read a stored payload, decide which fields matter, map them to a table.

**Incremental.** Each collector keeps a high-water mark, so a run fetches only
the days it has not got. The mark advances only across an unbroken run of
successes — a day that fails holds it back and is re-requested next run, rather
than being stepped over and lost. Runs that end with days still owed report
failure and name them.

A day that keeps failing cannot hold a collector back forever, though, or one
day Garmin will never serve would stop the archive growing. After
`MAX_DAY_ATTEMPTS` tries it is recorded in the `sync_gaps` table and stepped
over. Losing a day quietly and abandoning one on the record are different
things; `select * from sync_gaps` tells you which days are missing and why.

**A reality check.** Garmin has no official personal-data API and actively
blocks third-party clients (Cloudflare TLS fingerprinting since March 2026).
This leans on the community `garminconnect` library, which currently gets
through. When a Garmin change breaks it, the fix is usually to bump
`garminconnect` (and `curl_cffi`, which does the TLS impersonation) in
`requirements.txt` and rebuild. Treat that as a when-not-if: those two are
pinned for reproducibility, but they are the pins to move, because staying
current is the whole point of them.

## Running it daily

`garmin-sync` is a one-shot container: it runs, it exits. Scheduling is left to
your machine rather than baked in.

`deploy/` has systemd units — `garmin-sync.service` and `garmin-sync.timer` —
which is what the author uses. cron or any other scheduler works equally well;
the container just needs invoking once a day.

Pick your timezone deliberately with `TZ`. Garmin days are *local* days, so it
decides where one ends.

## The MCP server

`garmin-mcp` exposes the database to an AI assistant with a small set of
read-only tools — recent days, sleep, weekly trends, and a guarded query
surface.

**It has no authentication.** Anything that can reach the port can read your
entire health history. It therefore binds `127.0.0.1` unless you say otherwise.
To expose it further, set `MCP_BIND_ADDR` to an address on a private mesh
(Tailscale, WireGuard) or put an authenticating proxy in front. It warns on
startup if it is bound anywhere but loopback.

Read-only rests on `PRAGMA query_only`, which SQLite enforces itself and which
no SQL text can talk its way past. The single-statement SELECT guard and the row
cap sit on top as defence in depth — the guard blanks out string literals before
looking for write keywords, so searching for an activity called "Morning update"
is not mistaken for one. It is not `mode=ro`, deliberately — a read-only
handle cannot create the WAL `-shm` file, so it fails to open the database
whenever no writer is active, which is almost always.

## Configuration

Everything comes from `.env`. **`.env.example` lists every setting the code
reads**, commented out at its default — the table below is just the ones you are
most likely to touch.

| | |
|---|---|
| `GARMIN_EMAIL` / `GARMIN_PASSWORD` | your Garmin Connect login |
| `TZ` | decides where a "day" ends. Default `Etc/UTC` |
| `BACKFILL_START` | how far back the first run reaches. Ships as 2025-01-01. **Unset means about ten years — roughly 58,000 requests.** Widen it deliberately |
| `REQUEST_SLEEP` | pause between calls. Raise it if you see `429` |
| `MAX_CONSECUTIVE_FAILURES` | give up on a collector after this many failures in a row, so rate limiting ends the run instead of prolonging it. Default 10 |
| `MAX_DAY_ATTEMPTS` | give up on one stubborn day after this many attempts, recording it in `sync_gaps`. Default 5 |
| `MCP_HOST` | the address the server binds inside its own process. Loopback unless set |
| `MCP_BIND_ADDR` | Docker only: the host address the container's port is published on. Loopback unless set |
| `MQTT_HOST` | optional Home Assistant status publishing. Empty disables it |

Credentials are mounted as a file rather than passed as environment variables,
so Compose never tries to interpolate a `$` inside your password.

## Reading the data yourself

It is an ordinary SQLite file. Anything that opens one will do —
[DB Browser for SQLite](https://sqlitebrowser.org/), the `sqlite3` CLI, pandas,
a BI tool:

```bash
sqlite3 data/garmin.db \
  "select date, total_steps, resting_hr from daily_summary order by date desc limit 7"
```

## If you are not using Docker

The code is plain Python and runs directly — Python 3.12 or newer (the Garmin
client requires it), `pip install -r requirements.txt`, then
`python first_login.py` once and `python sync.py` on a schedule.
`run_sync.bat` and `setup_scheduler.ps1` register a Windows Task Scheduler job
for that arrangement. They are kept because they work, not because they are the
recommended path.

## Files

| | |
|---|---|
| `sync.py` | the incremental engine — the thing your scheduler runs |
| `collectors.py` | what gets pulled and how it is normalised. Edit to add or drop data |
| `database.py` | SQLAlchemy models and engine |
| `garmin_auth.py` | login and token persistence |
| `first_login.py` | one-time interactive login, handles 2FA |
| `mcp_server.py` | read-only MCP query server |
| `monitor.py` | optional MQTT status for Home Assistant |
| `config.py` | every setting, read from `.env` |
| `test_sync_gaps.py` | tests for the high-water mark and gap handling |
| `test_query_guard.py` | tests for the MCP query guard |
| `compose.yaml` | standalone deployment. Start here |
| `compose.garmin.yaml` | the author's homelab stack. Absolute paths, external network — it will not run elsewhere |
| `deploy/` | systemd units and the author's deploy script |

## Troubleshooting

**Login fails on a scheduled run.** Tokens expired — re-run `first_login.py`.
It is interactive, so it cannot happen unattended.

**`429 Too Many Requests`.** Garmin's rate limit. Wait, and raise
`REQUEST_SLEEP`. Long backfills are the usual cause.

**A whole data type is empty.** Your device probably does not record it. The
sync still succeeds and stores nothing for that type.

**The container cannot write to `data/`.** The image runs as uid 1000, which
must own the mounted directories. Rebuild with
`docker compose build --build-arg APP_UID=$(id -u)`.

**A run reports gaps.** Some days failed and are still owed. The next run
retries them; if it keeps happening, the log names the collector and date.

**Home Assistant shows "degraded".** The schedule is healthy but the archive is
not complete — some days were given up on. `select * from sync_gaps where
abandoned = 1` lists them. Green means up to date *and* nothing missing.

## Prior art

[GarminDB](https://github.com/tcgoetz/GarminDB) is the mature option — more
data types, FIT-file parsing, its own analysis notebooks. Use it if you want
depth.

This exists for a narrower shape: a container that pulls yesterday and stops, a
database an assistant can read directly, and a status signal for Home
Assistant. That is a deployment preference, not a claim to be better.

## Status

Published as reference and not actively maintained. It runs daily on the
author's server; that is the extent of the testing. MIT.

Maintenance

ActivitySlowing
ResponsivenessNo issues