Skip to content

Repository files navigation

OpenCycling

OpenCycling overview

A lightweight, open-source desktop application for **structured indoor cycling workouts in ERG mode**. OpenCycling connects to a smart trainer over Bluetooth, runs `.zwo` workouts by setting the target power automatically, and lets you review each session afterwards. Everything runs offline, with no account and no subscription.

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.


🚴 For Users

πŸ”Œ Compatibility

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 .zwo workouts. 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.

✨ Features

BLE scanning & connection

  • Automatic BLE scanning. Connects the trainer (required) and an optional heart-rate monitor, with separate status for each.

Session

  • Structured .zwo workouts. Reads workouts from a folder you choose; target watts come from each block's %FTP and 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.

Optional settings & third-party integrations

  • 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).

πŸ“Έ Screenshots

Connection

Connection page

Workout library and detail

Workout library Workout detail

Live session

Live session

Session summary / history

Sessions Session detail

Settings

Settings

πŸš€ Getting Started

  1. Install the app, or build it from source (see the contributor section below).
  2. Open Settings and set your .zwo folder path, your FTP (watts), and your max heart rate (bpm).
  3. Power on your trainer. Scanning starts automatically on the connection page; connect the trainer (and your HRM if you use one).
  4. Pick a workout, start it, and begin pedalling to launch the session.

πŸ› Reporting a Bug or Requesting a Feature

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.


πŸ› οΈ For Contributors

🧱 Tech Stack

  • 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)

πŸ—οΈ Architecture

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.md is 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.

πŸ“¦ Prerequisites

βš™οΈ Build & Run

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 checking

Rust backend (from src-tauri/):

cargo test                  # run all unit tests
cargo test <test_name>      # run a single test by partial name match
cargo clippy                # lint

πŸ•ΉοΈ Simulation Mode (testing without hardware)

The 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 Trainer and Polar 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.

πŸ§ͺ Testing Conventions

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).

πŸ“„ License

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

About

OpenCycling is free and open-source indoor bike training app

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages