Fork. Built on ha-garmin-fitness-coach-addon and ha-garmin-fitness-coach-app by Anil Belur (Apache-2.0 / MIT), which in turn build on create-t3-turbo (MIT). Renamed to Pacer and maintained as a standalone, self-hosted add-on. Attribution and the statement of changes required by Apache-2.0 ยง4(b) are in NOTICE; the changes themselves are listed in pacer/CHANGELOG.md.
AI-powered sport scientist that turns your Garmin data into actionable coaching, training analysis, and recovery optimization โ running entirely on your local network.
- Features
- Architecture
- Installation
- Configuration
- Garmin Authentication
- AI Backend Options
- Automation Blueprints & Templates
- Garmin Watch Compatibility
- Troubleshooting
- Development
- Contributing
- Disclaimer
- License
- ๐๏ธ Training Load Analysis โ CTL / ATL / TSB (Banister fitness-fatigue model), ACWR injury-risk tracking (Hulin 2016)
- ๐ Zone Analytics โ HR zone distribution, Seiler polarization index, efficiency trends, calendar heatmap
- ๐ง AI Specialist Agents โ Sport scientist, psychologist, nutritionist, recovery coach (via HA Conversation, local Ollama, or rules-based)
- ๐ Race Predictions โ Riegel formula for 5K / 10K / half-marathon / marathon
- ๐ค Sleep Coaching โ Sleep debt tracking, bedtime recommendations, quality trends, stage analysis
- ๐ 6+ Year Trends โ Long-term multi-metric overlay charts with rolling averages and notable-change detection
- ๐ฉบ Readiness Score โ Evidence-based daily score (0-100) using HRV, sleep, training load, and stress (Buchheit 2014)
- ๐จ Stress Board โ Scores calendar meetings against your heart rate (90-min local baseline, ridge regression over attendees) to rank which people spike โ or calm โ your HR; links Google Calendar and merges HA-logged out-of-calendar interactions
- ๐ Fully Private โ All data stays local; AI runs on your hardware
Pacer runs as a single s6-overlay service (pacer) inside the add-on
container. On boot it waits for PostgreSQL, pushes the Drizzle schema, then
starts the web app and several background loops. Startup order:
postgresql โ garmin-auth (parallel) โ pacer โ sync โ metrics โ notify โ
Next.js (:3001) โ ingress proxy (:3000) โ monitoring loop.
| Component | Port / cadence | Role |
|---|---|---|
| postgresql | 5432 | embedded storage; schema via drizzle-kit push |
| garmin-auth | 8099 | Flask auth server (web login + OAuth session) |
| garmin-sync.py | every N min | pull Garmin Connect data |
| strava-sync.py | every N min | optional โ pull Strava, if configured |
| metrics-compute.py | every N min | CTL/ATL/TSB, ACWR, VO2max, recommendations |
| ha-notify.py | every N min | push HA sensors via the Supervisor API |
| memory rebuild | nightly | Ollama embeddings for coach memory (only with Ollama) |
| stress rescore | every 6 h | re-score recent meetings once HR arrives |
| Next.js | 3001 | web UI, served behind the ingress proxy |
| ingress proxy | 3000 | HA ingress entry โ 3001, issues a per-boot auth token |
The Garmin and Strava credentials are given only to the Python workers and the auth server โ neither the web app nor the internet-facing proxy inherits them. The ingress proxy is the only process reachable from the hassio network and carries no secrets beyond its own per-boot token.
Supported architectures: amd64, aarch64.
Pacer is installed as a local add-on: Home Assistant builds the image on your own machine from this repository. There is no prebuilt image and no public add-on store entry.
- Copy the
pacer/folder of this repository to/addons/pacer/on your Home Assistant host. HA expectsconfig.jsonto sit directly beneath the add-on folder, so copypacer/, not the repository root. Over SSH:The Samba or "Advanced SSH & Web Terminal" add-ons both exposersync -a --delete pacer/ root@<haos-host>:/addons/pacer/
/addons. - In Home Assistant go to Settings โ Add-ons โ Add-on Store โ โฎ โ Check for updates. Pacer appears under Local add-ons.
- Click Install. The first build takes roughly 5 minutes on amd64 and 10-15 minutes on aarch64, since the Next.js app is compiled from source.
- Start the add-on โ it appears in your sidebar automatically.
To update, re-copy the folder and rebuild from the same screen.
- Open the addon from your HA sidebar (or Settings โ Add-ons โ Pacer โ Open Web UI).
- Complete the onboarding wizard (4 steps):
- About You โ age, sex, weight, height
- Your Sports โ select sports and goals for each
- Weekly Schedule โ training days and session duration
- Health & Safety (optional) โ health conditions, injuries, medications
- Connect Garmin โ go to Settings โ Connect Garmin, enter your email and password. If MFA is enabled, enter the verification code when prompted.
- Wait for initial sync โ the first sync pulls your full Garmin history (up to 6+ years). This takes 30-45 minutes due to Garmin API rate limits. You can monitor progress in Settings (a progress bar shows sync status). Subsequent syncs only pull the last 7 days and take ~30 seconds.
- Restart the addon after the first sync completes to trigger the metrics compute and HA sensor push.
๐ก Tip: You can trigger a manual sync at any time from Settings โ ๐ Sync Now without waiting for the next scheduled interval.
| Option | Type | Default | Required | Description |
|---|---|---|---|---|
garmin_email |
โ | No | Your Garmin Connect email (or use web-based login in Settings) | |
garmin_password |
password | โ | No | Your Garmin Connect password (or use web-based login in Settings) |
ai_backend |
list | none |
No | AI coaching backend (ha_conversation, ollama, openrouter, or none) |
openrouter_api_key |
password | โ | No | API key for the openrouter backend |
openrouter_model |
string | โ | No | Model slug for the openrouter backend |
ollama_url |
url | โ | No | Ollama server URL (only when ai_backend is ollama) |
sync_interval_minutes |
integer | 60 |
No | How often to pull new data from Garmin (5 โ 1440 minutes) |
Pacer authenticates with Garmin Connect using a web-based auth flow:
- Open the addon Web UI (sidebar โ Pacer).
- Navigate to Settings โ Connect Garmin.
- Enter your email and password. If your account has MFA enabled you will be prompted for the one-time code during the same flow.
- On success the addon stores an OAuth session token locally in
/data/garmin-tokens/. No credentials are sent to any third-party service.
Token lifetime: The session token is valid for roughly one year before Garmin forces a re-authentication. The addon will surface a notification when a token refresh is needed.
| Backend | Description |
|---|---|
openrouter |
Calls OpenRouter's chat-completions API directly; supports ZDR (zero-data-retention) and EU-only provider pinning for privacy. |
ha_conversation |
Routes prompts through the Home Assistant Conversation API to whatever agent you have configured (e.g., OpenAI, Claude, local LLM). Zero extra setup if you already use one. |
ollama |
Direct HTTP connection to a local Ollama instance โ fully private, runs on your hardware. Set ollama_url to the instance address. Also powers coach memory (RAG) via embeddings. |
none (default) |
Rules-based coaching only โ no LLM required. Still provides all data-driven insights, readiness scores, and training-load analytics. |
ha-notify.py pushes a set of Home Assistant sensors via the Supervisor API, including:
| Entity ID | Description |
|---|---|
sensor.pacer_ctl |
Chronic Training Load (42-day fitness) |
sensor.pacer_atl |
Acute Training Load (7-day fatigue) |
sensor.pacer_form |
Training Stress Balance (TSB = CTL โ ATL) |
sensor.pacer_acwr |
Acute:Chronic Workload Ratio (injury risk) |
sensor.pacer_injury_risk |
Risk level: Low / Moderate / High / Very High |
sensor.pacer_body_battery |
Current Garmin Body Battery value |
sensor.pacer_sleep_debt |
Accumulated sleep debt (hours) |
sensor.pacer_fitness_age |
VO2max expressed as an age against the HUNT3 reference cohort, with delta_years |
sensor.pacer_bedtime_target |
Next target bedtime as a timestamp, with local_time and anchor |
sensor.pacer_wake_window |
Wake window as HH:MM-HH:MM, with start / end / target |
sensor.pacer_data_quality |
Unresolved sync-gap count, with missing_days_14d / stale_days / field_gaps / status attributes |
Six ready-to-import Home Assistant blueprints are included in
pacer/rootfs/app/blueprints/. Import them via Settings โ Automations
โ Blueprints โ Import Blueprint using the raw GitHub URL:
| Blueprint | Trigger | What It Does |
|---|---|---|
| Low Body Battery Recovery | Body Battery < threshold | Dims lights, activates recovery scene, sends push notification |
| Morning Training Briefing | Configurable time (default 7 AM) | TTS announcement + push with ACWR, form, and workout recommendation |
| Injury Risk Alert | Risk level โ high or critical | Urgent push notification, optional DND toggle |
| Training Freshness Reminder | TSB (form) > threshold | Push notification to train when body is fresh |
| Weekly Training Summary | Configurable day/time | Weekly CTL, ATL, TSB, ACWR, risk, body battery summary |
| Wind-Down Reminder | Configurable offset before sensor.pacer_bedtime_target |
Push reminder with tonight's target bedtime and wake window, optional scene + dimmed lights |
All blueprints use configurable inputs (thresholds, notification targets,
scenes) with sensible defaults for Pacer sensor entities. Seven further
ready-to-paste automations (voice ACWR query, sleep-debt management, and
more) live in HA_AUTOMATIONS.md.
The engine unit tests cover the deterministic maths โ readiness, strain, ACWR, CTL/ATL/TSB, VO2max, forecasting and the daily recommendation rules:
cd pacer/app && pnpm --filter @acme/engine test
These matter because readiness scoring and workout recommendation are
implemented twice โ in Python for the Home Assistant sensors and in
TypeScript for the web UI โ and the accuracy-reference spec is what keeps
the two in agreement.
Pacer connects to the Garmin Connect web API โ not directly to your watch. Any Garmin watch that syncs to Garmin Connect will work, but the depth of coaching features depends on which sensors your watch has.
Watches with Body Battery, HRV, VO2 Max, and Training Status:
- Forerunner 165, 255, 265, 955, 965
- Fenix 7, 7X, 8, 8X
- Epix (Gen 2), Epix Pro
- Enduro 2, 3
- MARQ (Gen 2)
All metrics available: training load (CTL/ATL/TSB), ACWR injury-risk, HR zone polarization, recovery scores, sleep staging, Body Battery trends, HRV status.
Watches with HR + sleep but limited/no Body Battery or HRV:
- Vivoactive 4, 5
- Venu 2, 2 Plus, 3, Sq, Sq 2
- Instinct 2, 2X, Crossover, Solar
Most coaching works. Body Battery and HRV-based recovery may show as unavailable. Training load still calculates from HR zones.
Watches with steps + HR only (no advanced physiology):
- Vivosmart 4, 5
- Vivofit 4, Jr. 3
- Lily (Gen 1, 2)
Steps, heart rate, and sleep duration are available. Advanced training metrics (VO2 Max, Training Status, Body Battery) will not be populated.
Note: Pacer handles missing data gracefully โ sensors for unavailable metrics simply show as "Unknown" in Home Assistant.
Garmin limits OAuth logins but not token refreshes. Once authenticated,
saved tokens in /data/garmin-tokens/ make every later sync a refresh (not
a login). Problems arise on fresh installs where the addon falls back to
garmin_email/garmin_password logins.
Fix: stop the addon, wait 15โ30 minutes (up to 1โ2 h if it re-fails),
and start again for one clean login; tokens are then re-saved. Avoid
frequent uninstall/reinstall cycles โ a normal reinstall restores the
tokens from /share/pacer/garmin-tokens/ automatically.
First check the Log tab. If you see No Garmin credentials or saved tokens โ skipping auto-sync, run Settings โ Connect Garmin to
authenticate. The first sync pulls up to 6+ years of history and takes
30โ45 minutes (rate-limited); watch progress via Settings โ Sync
Now. Subsequent syncs take ~30 seconds.
MFA codes expire after ~60 seconds โ enter them promptly. OAuth tokens expire after roughly a year; re-authenticate from Settings โ Connect Garmin.
All data is stored in PostgreSQL at /data/postgresql/ and backed up after
every sync and on shutdown.
| Data | Location | Backup |
|---|---|---|
| Daily metrics, activities, VO2max, profile, readiness | PostgreSQL /data/ |
/share/pacer/pacer.sql.gz |
| Garmin OAuth tokens | /data/garmin-tokens/ |
/share/pacer/garmin-tokens/ |
On reinstall with an empty database, Pacer restores the database and tokens
from /share/pacer/ automatically โ no manual step needed.
| Field | Source | Editable? |
|---|---|---|
| Age, sex, weight, height | User input (Settings) | โ |
| Goals, weekly schedule | User input (Settings) | โ |
| Health conditions, injuries, meds | User input (Health & Safety) | โ |
| Resting HR, HRV baselines | Computed from Garmin | โ |
| VO2max, lactate threshold | Synced from Garmin API | โ |
Every metric uses published, peer-reviewed formulas verified by automated accuracy tests. Stress and HRV are read directly from your Garmin watch โ identical to what Garmin Connect shows.
| Chart | Our Method | vs Garmin / WHOOP |
|---|---|---|
| Body Stress | Direct Garmin API (avgStressLevel) |
Identical to Garmin |
| HRV Trend | Direct Garmin API | Identical to Garmin |
| Training Strain | TRIMP โ รใฐใค(1-e^(-TRIMP/250)) |
ยฑ1โ2 pts vs WHOOP |
| ACWR | 7d / 28d strain ratio | Hulin et al. (2016) formula |
| VO2max | Uth: 15.3 รใฐใค (maxHR/restHR) |
ยฑ3โ5 mL/kg/min vs lab |
| Race Predictions | Riegel: T2 = T1 รใฐใค (D2/D1)^1.06 |
ยฑ2โ5% for trained runners |
| Readiness | Weighted z-scores (HRV 35%, sleep 25%, load 20%, RHR 10%, stress 10%) | Trends match; values differ (open formula vs proprietary ML) |
| Recovery Time | Strain รใฐใค base hours, adj. for sleep/HRV/RHR | ยฑ4โ8h (simpler model) |
| Sleep Score | Duration 40%, efficiency 25%, deep 20%, REM 15% | Similar components, different weights |
Key takeaways: Stress & HRV are the exact Garmin numbers; Strain uses the same 0โ21 scale as WHOOP (ยฑ1โ2); VO2max & Readiness trends match Garmin/WHOOP but absolute values differ by 5โ10%. Every formula is open-source โ no black box. Sources: Banister (1991), Hulin et al. (2016), Uth et al. (2004), Cooper (1968), Riegel (1981), Hausswirth & Mujika (2013), Hirshkowitz et al. (2015), Moore (2016).
This repository is self-contained. The add-on packaging lives in pacer/
and the Next.js / tRPC / Drizzle application it serves lives in
pacer/app/, built from local source by the Dockerfile. See CLAUDE.md for
the layout and architecture notes.
- Docker
# Build the addon Docker image ./scripts/build-local.sh # Build and run (accessible at http://localhost:3100) ./scripts/build-local.sh --run # Remove built images ./scripts/build-local.sh --clean
# From the repository root
python -m pytest tests/ -vCI runs a multi-stage Docker build (Node.js builder โ HA base image) and
pushes multi-arch images (amd64 + aarch64) to GHCR; tagged releases create
GitHub Releases. A release-gate workflow refuses to ship addon images when
the app repo's main checks are not green.
Contributions are welcome! Please see CONTRIBUTING.md for repository structure, local development setup, AI backend details, and the release process.
This project is not affiliated with, endorsed by, or connected to Garmin Ltd. or any of its subsidiaries. "Garmin", "Garmin Connect", "Body Battery", "Training Status", and related trademarks are the property of Garmin Ltd.
Pacer is an independent, community-developed project that reads publicly available user data from the Garmin Connect API. Use at your own risk.
This project is licensed under the Apache License 2.0. See LICENSE for the full text.
SPDX-License-Identifier: Apache-2.0