Peloton → COROS activity bridge. Fetches your Peloton workouts, converts them to Garmin FIT files, and uploads them to your COROS Training Hub - on a schedule, with a built-in status page.
Peloton API ──► FIT builder ──► zip+md5 ──► COROS upload ──► state
(pylotoncycle) (fit_tool) (unofficial API)
- Backfill sync: picks up any workout from the last N days (default 7) that is not already in COROS - survives downtime and restarts.
- All disciplines: cycling, treadmill/outdoor running, rowing, walking, strength, yoga, stretching, meditation, pilates, bootcamps, cardio. Per-second metrics (power, cadence, heart rate, speed, distance) where Peloton provides them; summary-level sessions otherwise, with strength class plans exported as exercise structures.
- Dedupe, two ways: a local state file and reconciliation against the COROS import list (so a lost state file never double-uploads).
- Status page: HTML at
/, JSON at/api/status, Prometheus metrics at/metrics, manual trigger viaPOST /sync. Optional bearer-token gate. - One-shot or service:
pelocore syncfor k8s CronJobs,pelocore runfor a long-running Deployment with the status server.
What COROS does with an imported activity is decided by its importer, based on the FIT encoding alone. I mapped that behavior exhaustively by re-uploading one real session with every candidate encoding (scripts/experiment_stretch_sport.py); the full importer model lives in docs/coros-importer.md. Pilates is the lone bright spot: it imports as Pilates (905), the only non-GPS discipline that maps faithfully. Everything else has a catch:
- yoga, stretching and meditation import as Gym Cardio (400). COROS's
import pipeline cannot produce its Yoga category (904) - that exists only
on the watch path, and neither the COROS app nor the API can change an
activity's type after import.
GENERICis the closest reachable category. - bootcamps import as Strength (402). Hybrid Fitness (1200) and grouped Multisport (10001) are watch-path only; FITs encoding them are rejected.
- walking imports land uncategorized in COROS.
- Muscle heatmaps are unavailable for imported activities (COROS derives them server-side from its own plan sessions).
- Training Load for imported non-GPS workouts needs heart rate: without HR data COROS reports load 0. See Heart rate setup below.
- Activity names: the importer ignores FIT-provided names, so pelocore renames every activity to its Peloton title right after import.
Requires mise (Python 3.13, uv, ruff come from
mise.toml).
$ mise run install # uv sync --all-groups
$ export [email protected]
$ export PELOTON_PASSWORD='...'
$ export [email protected]
$ export COROS_PASSWORD='...'
$ mise exec -- pelocore doctor # validates both sides
$ mise exec -- pelocore sync # one-shot sync; safe to run on a schedulepelocore sync is designed to need no state: dedupe runs against the COROS
import list (server-side, retained for many months) and the import pipeline
itself is md5-keyed, so even a re-upload of identical bytes cannot create a
duplicate. Anything that can run a container on a schedule is a complete
deployment. The shipped k8s CronJob mounts no volume:
$ kubectl create namespace pelocore
$ cp deploy/k8s/secret.example.yaml secret.yaml # fill in, then:
$ kubectl -n pelocore apply -f secret.yaml
$ kubectl -n pelocore apply -f deploy/k8s/cronjob.yaml # every 15 minThe published image runs anywhere you can schedule a container
(Container image). pelocore run (long-running, with
the status page) is the alternative: mise run start locally, or
deployment.yaml in k8s.
| Variable | Default | Meaning |
|---|---|---|
PELOTON_USERNAME / PELOTON_PASSWORD |
- | Peloton credentials |
PELOTON_REFRESH_TOKEN |
- | Alternative to password login |
COROS_EMAIL / COROS_PASSWORD |
- | COROS credentials |
COROS_ACCESS_TOKEN |
- | Browser session token instead of password (value of the CPL-coros-token cookie on training.coros.com) |
COROS_REGION |
en |
en, eu, or cn (uploads unsupported on cn) |
PELOCORE_TIMEZONE_QUARTERS |
host offset | COROS timezone in quarter-hours east of UTC (32 = UTC+8) |
PELOCORE_BACKFILL_DAYS |
7 |
How far back to look for unsynced workouts |
PELOCORE_SPORT_REMAPS |
none built-in | Optional discipline remaps, e.g. meditation=yoga; the default categories are listed under Limitations |
PELOCORE_AUTO_UPGRADE |
on |
When a conversion change would produce different FIT bytes, delete the stale COROS activity and re-upload automatically. Conservative: ambiguous matches are skipped, never deleted blind. Set off to keep converter upgrades from re-importing history |
PELOCORE_IMPORT_POLL_SECONDS |
60 |
How long to poll COROS import status per upload |
PELOCORE_STATE_PATH |
data/state.json |
State file location |
PELOCORE_SERVER_HOST / PELOCORE_SERVER_PORT |
0.0.0.0 / 8080 |
Status server bind |
PELOCORE_STATUS_TOKEN |
- | When set, /, /api/status, /metrics, POST /sync require Authorization: Bearer <token>; probes stay open |
PELOCORE_LOG_LEVEL |
INFO |
Log verbosity |
Credentials may also live in a .env file (pydantic-settings loads it).
pelocore sync # run a single sync cycle and exit (cron mode)
pelocore run # loop mode: internal scheduler + status server
pelocore status # print cycle history from the state file
pelocore doctor # verify Peloton and COROS credentials
Exit code of pelocore sync is 1 when the cycle outcome is failed
(partial failures still exit 0; inspect via pelocore status or the logs).
pelocore implements the reverse-engineered Training Hub upload protocol:
POST /account/loginwith an MD5-hashed password (or paste a browser token viaCOROS_ACCESS_TOKEN) →accessToken.- STS credentials for a per-region S3 bucket (Training Hub web proxy, legacy open endpoint as fallback).
- The FIT file is zipped (
{md5}/peloton-{workoutId}.fit, stored) and PUT tos3://{bucket}/fit_zip/{userId}/{md5}.zipwith SigV4. POST /activity/fit/importregisters the object; import progress is polled viaactivity/fit/getImportSportList(status == 2= success).
If COROS rotates the STS signs, uploads fail with 401 signature error
until the constants in src/pelocore/coros.py are updated.
$ pelocore sync --since 2026-02-01 --chunk-size 10 --chunk-delay 30--since YYYY-MM-DDoverrides the 7-day window (365-day cap); Peloton's history pagination supports up to 1000 workouts.--chunk-size/--chunk-delaypace the upload: after every N uploads the bridge pauses S seconds, keeping the request rate gentle on both APIs. Workouts upload oldest-first, so an interrupted run leaves the most recent ones for last.- Backfills are resumable: state + the COROS import list dedupe every workout, so re-running skips what already landed (and even a full state loss cannot create duplicates, since the import pipeline is md5-keyed).
--dry-runprints the plan (new/skip per workout) without uploading or re-fetching performance data.- A 280-workout backfill (~8 months) takes roughly 45-70 minutes including chunk delays; per-workout failures are logged and retried on the next run.
- Why it is safe: the import pipeline is keyed by FIT content md5 - identical bytes yield the same import job and never a second activity - and the import list retains entries for many months. A lost state file costs wasted uploads, never duplicates.
- The schedule is the knob: a CronJob run costs ~3 light reads when
there is nothing to do. The shipped default is
*/15; relax it to lower API traffic. - Serverless (Cloud Run Jobs, etc.): works the same way; tune
PELOCORE_IMPORT_POLL_SECONDSdown if per-invocation time is capped, since polling (import + rename resolution) dominates runtime. AWS Lambda's 15-minute cap fits small backfills only.pelocore syncis one-shot and ignoresPELOCORE_SYNC_INTERVAL_SECONDSentirely - that setting only applies topelocore run. - State file is optional: it adds cycle history for the status page and skips already-known workouts (fewer API calls), but is never required for correctness.
- API observability:
/metricsreportspelocore_peloton_api_calls_totalandpelocore_coros_api_calls_totalso the request budget stays visible. - Converter upgrades: FIT filenames carry the converter version
(
peloton-<id>.fit= v1,peloton-<id>.v2.fit= v2). When the converter changes, sync can detect stale versions and upgrade, deleting the old activity and re-uploading. SetPELOCORE_AUTO_UPGRADE=offif you do not want history re-imported - with it off, only workouts imported after the upgrade get the new behavior.
COROS computes Training Load in the context of your other activities, but Peloton workouts without heart rate data give it nothing to work with: for imported strength, yoga and stretching sessions it reports load 0. pelocore bridges that gap, but only if your Peloton workouts capture heart rate in the first place.
Peloton hardware and the Peloton app cannot read a wrist-based wearable directly. The way to get your HR into Peloton workouts is to broadcast heart rate from the COROS watch (Settings → Broadcast Heart Rate on supported models) and pair that broadcast as a standard Bluetooth heart-rate sensor with whatever is running the class:
- Peloton Bike / Tread / Row hardware: add the broadcast as a Bluetooth HR sensor in the device's sensor-pairing screen.
- Peloton Guide: pair the broadcast as a Bluetooth HR sensor in its settings.
- Peloton app (tablet or phone): pair the broadcast as a Bluetooth HR sensor from the app's heart-rate settings before starting the class.
Once paired, HR is captured by Peloton per workout and flows through pelocore into the generated FIT files. That is what COROS needs to compute Training Load for strength, yoga and stretching imports (and gives cycling/running their full HR metrics).
Both modes are shipped under deploy/k8s/:
cronjob.yaml- one-shotpelocore syncevery 15 minutes. No volume needed: dedupe runs against the COROS import list. Visibility: logs.deployment.yaml- long-runningpelocore runwith the status page (ClusterIP Service, liveness/readiness probes, optional PVC for state). Keep it at 1 replica: syncers would race on the state file.
Multi-arch (linux/amd64, linux/arm64), non-root (uid 65532), released to
GHCR on tags:
ghcr.io/rhass/pelocore:vX.Y.ZPull and run:
$ docker run -d -p 8080:8080 -v pelocore-data:/data \
-e PELOTON_USERNAME=... -e PELOTON_PASSWORD=... \
-e COROS_EMAIL=... -e COROS_PASSWORD=... \
ghcr.io/rhass/pelocore:vX.Y.ZImages are signed with Sigstore cosign (keyless) and carry SBOM + provenance attestations:
$ cosign verify \
--certificate-identity-regexp \
'^https://github\.com/rhass/pelocore/\.github/workflows/release\.yml@refs/tags/v.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/rhass/pelocore:vX.Y.Z
$ cosign verify-attestation --type slsaprovenance \
--certificate-identity-regexp \
'^https://github\.com/rhass/pelocore/\.github/workflows/release\.yml@refs/tags/v.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/rhass/pelocore:vX.Y.Z$ mise run install # deps
$ mise run test # pytest (unit + mocked integration)
$ mise run lint # ruff
$ mise run typecheck # mypy (strict)Live smoke tests against the real services are opt-in:
$ PELOCORE_LIVE=1 mise exec -- uv run pytest -m liveRelease and CI internals (tag signing, local act runs, Chainguard base image, the release version bump) are documented in AGENTS.md.
Local credentials live in .mise.local.toml (git-ignored; see
.mise.local.toml.example). The recommended
pattern uses 1Password secret references instead of plaintext values:
-
Add the 1Password CLI to the toolchain and sign in:
$ mise use 1password # adds [tools] 1password = "latest" (to your local toml) $ op signin # or unlock via the 1Password desktop app
-
Point env values at vault items -
op://<vault>/<item>/<field>:[env] PELOTON_PASSWORD = "op://Private/pelocore/peloton-password" COROS_PASSWORD = "op://Private/pelocore/coros-password"
-
Wrap commands with
op run --, which resolves everyop://reference in the environment for the child process. The template ships task overrides for this (mise start,mise run-sync,mise run-doctor):[tasks.start] run = "op run -- uv run pelocore run"
Secrets resolved by op run -- exist only in the child process environment -
references never touch disk or shell history. Plain values also work in
.mise.local.toml but are stored in plaintext; prefer references.
- Trivy scans run on every PR (filesystem: dependency vulns via
uv.lock+ secrets; config: Dockerfile/k8s misconfigurations) and on every release (image gate, HIGH/CRITICAL, unfixed ignored). - Keyless cosign signatures publish image digests to the public Rekor transparency log. Fine for a public repo; be aware if you fork privately.
- The status page never renders credentials, only workout metadata and
errors. Protect it with
PELOCORE_STATUS_TOKENwhen exposing beyond the cluster.
This project is not affiliated with, endorsed by, or approved by Peloton Interactive, Inc. or COROS Wearables, Inc. It depends on unofficial, reverse-engineered APIs that may change or stop working at any time, and its use may violate the respective Terms of Service. Use at your own risk, with your own credentials, for personal data you own.
Apache-2.0 - see LICENSE.