- Python 99.8%
- Dockerfile 0.2%
| app | ||
| data | ||
| docs | ||
| tests | ||
| .dockerignore | ||
| .env.docker.example | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| pyproject.toml | ||
| README.md | ||
Polar Gamification API
Small FastAPI service and website for a single Polar AccessLink user. The public start page is the Polar-Expedition gamification status page. Polar OAuth, Daily Activity, raw data views and local editing tools remain available as supporting/maintenance views.
Polar API Basis
The implementation follows the official Polar AccessLink API v3 documentation:
- Authorization endpoint:
GET https://flow.polar.com/oauth2/authorization - Token endpoint:
POST https://polarremote.com/v2/oauth2/token - Register user:
POST https://www.polaraccesslink.com/v3/users - Daily activity by date:
GET /v3/users/activities/{date} - Exercises:
GET /v3/exercises - Exercise sample type mapping follows the official AccessLink appendix:
0=heart_rate bpm,1=speed km/h,2=cadence rpm,3=altitude m,4=power W,5=power_pedaling_index %,6=power_left_right_balance %,7=air_pressure hpa,8=running_cadence spm,9=temperature C,10=distance m,11=rr_interval ms
All Polar HTTP calls are isolated in app/services/polar_client.py, so endpoint paths can be changed later if Polar API versions diverge.
Setup
cd polar-gamification-api
py -3.12 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -e ".[dev]"
Copy-Item .env.example .env
Fill .env with your Polar AccessLink client values:
POLAR_CLIENT_ID=...
POLAR_CLIENT_SECRET=...
POLAR_REDIRECT_URI=http://127.0.0.1:8000/auth/callback
APP_BASE_URL=http://127.0.0.1:8000
DATABASE_URL=sqlite:///./polar_state.sqlite3
TIMEZONE=Europe/Berlin
DAILY_ACTIVITY_TODAY_TTL_MINUTES=20
POLAR_DATA_REFRESH_INTERVAL_MINUTES=60
GAME_WIND_SPOT_NAME=San Pepelone
GAME_WIND_LATITUDE=54.036329
GAME_WIND_LONGITUDE=11.578261
GAME_WIND_THRESHOLD_KNOTS=18
GAME_WIND_FORECAST_HOURS=48
GAME_WIND_CACHE_TTL_MINUTES=60
Start the API:
uvicorn app.main:app --reload
Open the website:
Start-Process http://127.0.0.1:8000/
Docker / lokales Netzwerk
Das Projekt kann als Alpine-basiertes Docker-Image gebaut und per Compose gestartet werden.
Die SQLite-Datenbank liegt auf dem Host im lokalen Verzeichnis ./data/polar_state.sqlite3
und wird im Container nach /data/polar_state.sqlite3 gemountet. Damit kannst du data/
einfach in deine normalen Backups aufnehmen.
Forgejo-Check-in und Server-Deployment sind separat dokumentiert:
docs/forgejo-deploy.md.
Das Gamification-Konzept liegt in docs/gamification-concept.md.
cd polar-gamification-api
Copy-Item .env.docker.example .env
Passe in .env die Polar-Werte und die URL deines Servers im lokalen Netz an:
POLAR_CLIENT_ID=...
POLAR_CLIENT_SECRET=...
APP_BASE_URL=http://192.168.178.10:8000
POLAR_REDIRECT_URI=http://192.168.178.10:8000/auth/callback
TIMEZONE=Europe/Berlin
DAILY_ACTIVITY_TODAY_TTL_MINUTES=20
POLAR_DATA_REFRESH_INTERVAL_MINUTES=60
APP_UID=1000
APP_GID=1000
GAME_WIND_SPOT_NAME=San Pepelone
GAME_WIND_LATITUDE=54.036329
GAME_WIND_LONGITUDE=11.578261
GAME_WIND_THRESHOLD_KNOTS=18
GAME_WIND_FORECAST_HOURS=48
GAME_WIND_CACHE_TTL_MINUTES=60
Wichtig: POLAR_REDIRECT_URI muss exakt in der Polar AccessLink Admin UI als Redirect URL
hinterlegt sein. Für einen Server im lokalen Netz also nicht 127.0.0.1, sondern die
LAN-IP oder der lokale DNS-Name, den dein Browser erreicht.
Start:
mkdir data
docker compose up -d --build
docker compose logs -f
Auf Linux kannst du deine UID/GID so ermitteln:
id -u
id -g
Falls eine vorhandene Datenbank oder das Verzeichnis data/ schon mit falschen
Rechten existiert:
sudo chown -R "$(id -u):$(id -g)" data
chmod -R u+rwX data
Danach im Browser öffnen:
http://192.168.178.10:8000/
Stoppen:
docker compose down
Stoppen inklusive gelöschter lokaler SQLite-Daten:
docker compose down
Remove-Item -Recurse -Force .\data
The flow is:
/shows the Polar-Expedition gamification status page./auth/startredirects to Polar Flow for login and consent./auth/callbackstores the AccessLink token and registers the Polar user if needed./dashboardshows today's Daily Activity./setupkeeps the old login/status start page available.
Endpoints
GET /website Polar-Expedition gamification pageGET /setupwebsite setup/login status pageGET /dashboardwebsite activity pageGET /gamegamification summary as JSONGET /game/viewwebsite gamification page, same content as/GET /history/viewwebsite history pageGET /data/viewwebsite overview for all enabled Polar dataGET /local/viewwebsite for local synced records and editsGET /health-connect/viewwebsite for Android Health Connect bridge statusGET /healthGET /auth/startGET /auth/callbackGET /auth/logoutGET /todayGET /activity/{date}GET /raw/activity/{date}GET /activity/{date}/samplesGET /exercises/{date}GET /exercises/{date}/mappedGET /history?days=365GET /data/allGET /data/all?include_samples=trueGET /data/all?refresh=trueGET /cache/statusPOST /cache/clearGET /raw/physical-infoPOST /sync/exercisesGET /local/exercisesGET /local/exercises/{exercise_id}GET /local/exercises/{exercise_id}/mappedGET /local/exercises/{exercise_id}/rawPUT /local/exercises/{exercise_id}POST /local/exercises/{exercise_id}/discardPOST /sync/physical-infoGET /local/physical-infoPUT /local/physical-infoPOST /local/physical-info/discardPOST /polar/physical-inforeturns501, because Polar AccessLink has no write endpoint for physical infoPOST /health-connect/importGET /health-connect/dataGET /health-connect/pending-writesPOST /health-connect/pending-writes/ackGET /raw/activity/todayGET /raw/exercises/today
Notes
- Single-user mode stores one token/user state row in SQLite.
- Polar responses are cached in SQLite in
api_cache; default TTL is 6 hours. - Daily Activity for the current date is refreshed in the background with
DAILY_ACTIVITY_TODAY_TTL_MINUTES; default is 20 minutes, hard minimum is 15 minutes. Requests for today's Daily Activity also use that TTL as their cache boundary. Past dates keep the default cache behavior. - Empty Daily Activity responses from Polar (
204/null) are cached only for 15 minutes, so delayed watch/app syncs become visible without waiting for the full default cache TTL. - The server also refreshes the main Polar data in the background with
POLAR_DATA_REFRESH_INTERVAL_MINUTES; default is 60 minutes, hard minimum is 15 minutes. This refresh updates today's Daily Activity, Exercise caches, local Exercise records, Physical Info cache and local Physical Info. - The same background refresh stores additional Vantage V2 raw data in
api_cachefor later analysis: user profile, today's activity samples, continuous heart rate, cardio load, sleep, available sleep, nightly recharge, SleepWise alertness and SleepWise circadian bedtime. These datasets are not displayed or interpreted yet. - Editable local records are stored in SQLite in
local_records. - Synced Polar exercises are stored as JSON envelopes in
local_records.payload_json. Each record containsrawwith the full Polar response, includingroute, andmappedwith normalized fields, parsed samples, sample statistics, route points and route bounds. - Local edits set
dirty=1; syncing from Polar preserves dirty local payloads and stores the new Polar payload as source data. - Health Connect requires an Android companion app; see
docs/health-connect.md. - Gamification is computed from cached Daily Activity and local Exercise records. It exposes the Polar-Expedition state: XP, levels, weather-window streaks, day classes, quests, status effects, repair quests, watcher emotion/message, recent days, best days and achievements without writing back to Polar data.
- The Polar-Expedition also checks the San Pepelone wind forecast through Open-Meteo. When the
configured threshold is reached, default
18 knin the next48hours, the game opens aWindfensterand adds theExpedition steht anquest. - For another LLM or agent, use
docs/llm-api.mdas the integration contract. - Add
refresh=trueto supported endpoints to bypass the cache and update from Polar. .envand SQLite database files are ignored by git.- AccessLink access tokens do not expire unless revoked, according to Polar's documentation.
/todayuses the configured timezone, defaulting toEurope/Berlin.