Garmin Wellness Poller: Fix Brief¶
Date: 2026-04-24 Owner: garmin-fix_Opus47 worker Parent session: Main (Opus 4.7, Justin remote-control)
The Goal¶
Get /home/justinwieb/forge/scripts/integrations/garmin/poll.py successfully pulling daily wellness data (steps, body battery, stress, HRV, sleep) from Garmin Connect, pushing to Home Assistant REST API as sensors, and writing into the Context API (SQLite DB at /home/justinwieb/forge/data/context.db).
Cron already runs it twice daily (06:05 and 18:05), see crontab entry garmin/poll.py. It just needs to actually succeed.
Current Failure Mode¶
Last successful run: 2026-04-21T18:30 (3 days ago).
Since 2026-04-22, every run fails at the OAuth exchange step:
OAuth exchange failed: 401 Client Error: Unauthorized for url:
https://connectapi.garmin.com/oauth-service/oauth/preauthorized?ticket=ST-…-cas&login-url=https://sso.garmin.com/sso/embed&accepts-mfa-tokens=true
SSO is producing a valid CAS ticket, but exchanging it for OAuth credentials fails with 401.
One recent run (2026-04-23T22:00) also tripped Cloudflare bot detection, response body started with <title>Attention Required! | Cloudflare</title>. So Garmin may be fingerprinting repeated login attempts from this IP.
Earlier cron.log entries also show 429 rate-limiting from the mobile login path. The library seems to have multiple login backends (mobile cffi, mobile requests, embed flow) and all of them have rotted.
Files You'll Work With¶
| File | Purpose |
|---|---|
scripts/integrations/garmin/poll.py |
Main poller, auth + fetch + HA push + Context API write |
scripts/integrations/garmin/.env (check if exists) |
Maybe, credentials may live here OR in ~/.forge-secrets/wellness.env |
~/.forge-secrets/wellness.env |
Primary creds location (GARMIN_EMAIL, GARMIN_PASSWORD) |
scripts/integrations/.venv/ |
Shared integrations venv (has garminconnect, garth) |
logs/integrations/garmin.log |
JSONL log, read recent entries to see failure pattern |
logs/integrations/cron.log |
Combined cron output for all integrations |
N8n has the credential too: ID saT0X6MMAFLKrBOT of type httpBasicAuth. You can decrypt with n8n's encryptionKey (see reference_cloudflare.md for the decrypt pattern, same format, different cred).
Likely Root Causes (ranked)¶
- Outdated
garminconnect/garthlibrary. Garmin changes their SSO flow every few months; the pinned version in the venv might be pre-change. Start bypip list | grep -iE "garmin|garth"and checking latest on PyPI. - Library was installed with
garminconnectbut code references.garthattribute, warnings in log say'Garmin' object has no attribute 'garth'. So there's an API-shape mismatch between what poll.py expects and what the installed library exposes. - IP-level bot detection. Cloudflare challenge page once. May need to add delay/jitter, user-agent rotation, or use a session file so we're not doing fresh login every run.
- MFA now required on account, less likely but possible. Check connect.garmin.com manually.
- Password rotated / 2FA enrollment changed server-side, have Justin confirm the password still works via the app if the other fixes don't help.
Recommended Approach¶
- Look at
poll.pyfirst to understand the auth flow it's using. - Check library versions and diff against latest. The
garth-only approach (token-based, session-caching) is now the recommended path,garminconnectis a thin wrapper overgarth. - Try a minimal reproducer in a Python REPL: If this fails too, it's a library problem, not a poll.py problem.
- Once auth is working, verify the poll.py flow end-to-end: it should push sensors into HA and insert a row into
facts_wellnessin the Context API SQLite (or wherever it writes). - Don't lose the graceful-degradation path, earlier log entries show "ok, metrics: {}" even when auth failed partially. That means the script was swallowing errors. Make sure errors propagate to the log clearly but don't crash cron.
Testing¶
- Manual single run:
/home/justinwieb/forge/scripts/integrations/.venv/bin/python /home/justinwieb/forge/scripts/integrations/garmin/poll.py - Success looks like: JSONL log entry
"kind": "ok"with non-emptymetrics: {...}and a row in Context API (sqlite3 /home/justinwieb/forge/data/context.db "SELECT * FROM facts_wellness ORDER BY ts DESC LIMIT 3;") - HA sensor check:
curl -H "Authorization: Bearer $HA_TOKEN" "http://192.168.86.70:8123/api/states/sensor.garmin_last_poll"
Don't Do¶
- Don't blast Garmin login 20 times debugging, you'll get IP-blocked harder. Rate-limit yourself. Use fixtures / mocks once you've captured one good response.
- Don't leak credentials into logs or commit
.env/wellness.envto git. - Don't change the cron schedule, it's fine. Fix auth, leave the rest alone.
- Don't create new files outside
scripts/integrations/garmin/unless there's a clear reason.
Deliverables¶
poll.pysuccessfully auths, fetches, writes to HA + Context API- One clean manual run end-to-end (show output in log)
- Next cron run (06:05 tomorrow) logs a success
- Short note appended to this handoff file describing what the fix was
- If the library needed upgrade, bump it in requirements and document the pinned version
Registering Your Work¶
When done:
- Update the "Wellness Pollers" entry in /home/justinwieb/.claude/projects/-home-justinwieb-forge/memory/reference_wellness_pollers.md if anything about the setup changed
- Append to memory/daily/2026-04-24.md under your session checkpoint
- Ping back in this handoff file: I'll pick up the summary
Good hunting.
Fix Summary, 2026-04-24 13:17 CDT¶
Status: ✅ Fixed. End-to-end verified: login → HA sensors → Context API (10 facts).
Root cause: scripts/integrations/garmin/garmin_web.py (our custom web-SSO client) minted the service ticket against service=https://connect.garmin.com/modern but passed login-url=https://sso.garmin.com/sso/embed to oauth-service/oauth/preauthorized. Garmin's OAuth endpoint validates that the preauth login-url matches the ticket's originating service, hence the persistent 401 Unauthorized. Noteworthy: the "ok" cron runs on 2026-04-21 were already silently broken, metrics: {} every time, with a 'Garmin' object has no attribute 'garth' warning swallowed. So the last actually successful capture was older than the handoff suggested.
Fix: Rewrote poll.py to use garminconnect==0.3.3 directly (libraries were already at latest; they just weren't being used). Persistent tokenstore at ~/.garminconnect-tokens/garmin_tokens.json. First run does a fresh SSO handshake; subsequent runs skip SSO entirely and refresh the DI bearer via garth.
During the fresh login: /mobile/api/login returned 429 (cffi + requests both), but garminconnect's fall-back to the web-portal flow succeeded on the first try, no MFA prompt, no Cloudflare challenge.
Deliverables:
1. poll.py, rewritten, minimal, uses garminconnect + typed exception handling. Rate-limit-safe: single attempt per invocation, no retry on auth failure.
2. Verified 11 sensors pushed to HA (sensor.garmin_*).
3. Verified 10 rows in facts_wellness (manual ha_poller.py run, the cron will do this automatically every 15 min).
4. Old garmin_web.py and import_browser_session.py kept in-tree but no longer imported by poll.py. Safe to delete in a follow-up if Justin confirms, they're dead code now.
5. Cron schedule unchanged (06:05 / 18:05), next cron run will use the cached tokens and skip SSO entirely.
Updates to registry: see reference_wellness_pollers.md + today's daily log.
[Claude Code, garmin-fix_Opus47]