OpenCycling is an open alternative to commercial indoor training applications for cyclists who just want to run a structured ERG session and own their data.
OpenCycling speaks the standard BLE FTMS (trainers) and HRS (heart rate) protocols, so it should work with any compliant device. In practice, only the hardware below has been verified.
| Device | Type | Protocol | Status |
|---|---|---|---|
| Decathlon D500 | Smart trainer | FTMS (0x2AD2) | β Tested, reference device |
| Polar (H9 / H10 / OH1...) | Heart rate monitor | HRS (0x2A37) | β Tested |
| Other FTMS trainers | Smart trainer | FTMS (0x2AD2) | βͺ Should work, untested |
| Other HRS straps | Heart rate monitor | HRS (0x2A37) | βͺ Should work, untested |
ERG mode only. OpenCycling drives the trainer's target power and runs structured
.zwoworkouts. There is no free-ride / SIM (slope) mode and no manual resistance control. A trainer that does not support FTMS Set Target Power cannot be used. A heart rate monitor is optional.
Platform: the primary development and testing target is Windows. On Windows WinRT, BLE filtering by service UUID is unreliable, so devices are filtered by name prefix ("D500" for the trainer, "Polar" for the HRM). Non-Polar straps or non-D500 trainers may need the prefix filters adjusted in the code.
- Automatic BLE scanning. Connects the trainer (required) and an optional heart-rate monitor, with separate status for each.
- Structured
.zwoworkouts. Reads workouts from a folder you choose; target watts come from each block's%FTPand your FTP. - FTP ramp test. A built-in ramp test (power rises every minute in ERG) that watches for exhaustion: when you can no longer hold the target it counts down and prompts you to stop, then estimates your new FTP as 75% of your best 1-minute power and lets you apply it in one click.
- ERG control per block. Drives the trainer's target power every second, with linear ramps for warmups and cooldowns and a keep-alive so resistance never drops.
- Pedal-to-start. No countdown; the session begins automatically as soon as you start pedalling.
- Live session view. Current block and target, power, heart rate, cadence, elapsed and remaining time, and a block-by-block timeline.
- Audio cues. Beeps at start, end, each block transition, and counting down a block's final seconds.
- Pause, resume, and skip block during a session.
- Automatic recording. Power, heart rate, and cadence sampled every second to a local SQLite database, so a session survives an early stop.
- Session summary and history. Browse past sessions as cards and open any one for stats, a power graph, and six-zone power and heart-rate breakdowns based on your FTP and max HR.
- Aero position detection (optional, webcam). After a short calibration, a live indicator on the session screen shows whether you are aero or upright, and your overall time in aero is saved with the session and shown in history. Fully offline and off unless you enable it.
- TCX export. Export any session to a standard
.tcx(per-second power, HR, and cadence plus a workout-structure note) for Strava, Garmin Connect, or any TCX tool. - Strava upload (optional, manual setup). Push a finished session straight to Strava as a Virtual Ride. There is no shared account: to keep the app fully open you bring your own Strava app (Strava Premium required) and run a small auth proxy (about two minutes, see its setup guide).
![]() |
![]() |
![]() |
![]() |
- Install the app, or build it from source (see the contributor section below).
- Open Settings and set your
.zwofolder path, your FTP (watts), and your max heart rate (bpm). - Power on your trainer. Scanning starts automatically on the connection page; connect the trainer (and your HRM if you use one).
- Pick a workout, start it, and begin pedalling to launch the session.
Please use the GitHub issue tracker: github.com/TheElysium/opencycling/issues.
- For a bug, open a new issue and include your OS, your trainer and HRM models, the steps to reproduce, and what you expected vs. what actually happened. Attaching the log file from the app's log directory helps a lot.
- For a feature request, open an issue describing the use case and why it would help.
Please search existing issues first to avoid duplicates.
- Framework: Tauri v2 (desktop shell)
- Frontend: SvelteKit 5 (Svelte runes), TypeScript, Vite; TensorFlow.js + MoveNet (bundled offline) for webcam aero detection
- Backend: Rust (edition 2024), Tokio,
rusqlite,roxmltree,thiserror,tracing - Storage: SQLite (schema-migrated on startup)
The Rust backend separates pure parsers (no I/O, fully unit-tested) from Tokio actors that own all state and communicate over mpsc channels:
| Module | Responsibility |
|---|---|
ble/ftms |
Parses FTMS Indoor Bike Data (0x2AD2) and builds the Set Target Power command |
ble/hrs |
Parses HRS Heart Rate Measurement (0x2A37), 8- and 16-bit formats |
workout/zwo |
Parses .zwo XML into an ordered list of WorkoutBlock |
BleActor |
BLE scan/connect, ERG keep-alive, auto-reconnect on device loss, emits ble_metrics |
SessionActor |
Session state machine (WaitingForRider β Running β Paused β Finished), per-second tick, target-power output, emits session_metrics |
DbActor |
Wraps SQLite for sessions, per-second samples, and settings |
The frontend calls Rust through the typed commands object generated by tauri-specta into src/lib/bindings.ts (committed, never hand-edited), and Rust pushes live data back through Tauri events (ble_metrics, session_metrics, ble_error, ble_reconnect, ble_disconnected).
Aero detection follows the same split, on the frontend side: pose scoring is done entirely in the webview (lib/aero.ts pure functions, unit-tested; lib/aero.svelte.ts rune store owning the camera and MoveNet detector). The frontend makes the smoothed, debounced aero/upright decision and reports it over the report_aero bridge once per second; Rust stays "dumb" and just stores the value into each 1 Hz sample and averages it into the session's aero_pct.
Note:
docs/prd.mdis an early design document and has drifted from the implementation (for example, it describes a countdown and synthesized ramps that the code does not use). Treat the code as the source of truth.
- Node.js and pnpm
- Rust (stable toolchain)
- Tauri v2 system dependencies for your OS
Frontend and full app (from the repo root):
pnpm install
pnpm tauri dev # run the full Tauri app (frontend + Rust backend)
pnpm check # TypeScript / Svelte type checkingRust backend (from src-tauri/):
cargo test # run all unit tests
cargo test <test_name> # run a single test by partial name match
cargo clippy # lintThe BLE layer can be fully simulated, so you can exercise the whole app (sessions,
drops, reconnects) with no trainer or HRM attached. Set the OPENYCLING_SIM env
variable to 1 or true (case-insensitive) before launching:
$env:OPENYCLING_SIM = "1"; pnpm tauri dev- Scanning returns two fake devices,
D500 SIM TrainerandPolar SIM HRM; connecting is instant and always succeeds. - The app runs on a separate database (
opencycling-sim.db), so sim sessions never pollute real data. - A floating SIM chip (bottom-right, on every route) opens controls to Drop each device, with automatic recovery or a permanent loss, and to Restore it. A simulation card is also shown on the Settings page.
- Disconnects replay the real reconnect flow: attempts every 3 s up to the 10-attempt cap (~30 s), with the same modal (trainer) and inline feedback (HRM).
- The rest of the stack (
SessionActor,DbActor, Tauri commands, frontend) is unchanged and cannot tell the difference; without the env variable, the real BLE path is byte-identical in behavior.
Tests live in the same file as the code they test, inside a #[cfg(test)] module (no separate test files). Pure parsers and the session state machine are unit-tested. The actors are not unit-tested: BleActor depends on BLE hardware, DbActor on SQLite I/O, and SessionActor on the Tokio runtime. They are validated manually, or end to end through the simulation mode in src-tauri/tests/sim_e2e.rs, which drives the real SimActor stack on the tauri mock runtime with a paused tokio clock (the metric tick and reconnect cadence auto-advance instantly).
OpenCycling is licensed under the GNU General Public License v3.0 or later. See LICENSE for the full text.
This is a copyleft license: any redistributed version, including modified forks, must also be released as open source under the GPL.
Copyright (C) 2026 Luka Signe--Morice






