Olympus is a free, offline-first indoor-cycling app that runs in a terminal. Think Zwift or TrainerRoad, but keyboard-driven, in your terminal: it connects straight to your smart trainer over Bluetooth, runs structured workouts with live resistance control, tracks you as you ride, and exports a standard fitness file you can upload to Garmin Connect / Strava. No subscription, no cloud, no account.
What you need
- A computer on Linux, macOS, or Windows 10+
- Any modern smart trainer with Bluetooth (an HR strap is optional)
- The Rust toolchain (
cargo)
Get riding
cargo run --release- Settings β Bluetooth β scan and pair your trainer. An optional heart-rate strap pairs automatically as a second device.
- Database β Workouts β pick a workout and start the ride. Brand-new riders are pointed at the Ramp Test, which estimates a starting FTP.
- Ride on the Control panel, then
Qto finish and Save the ride.
That's it. Your rides land as .fit files in data/.fit/ plus a row in the local history database.
- Drives any modern smart trainer β it speaks the open Bluetooth Fitness Machine Service, so no trainer brand is special-cased.
- Runs structured workouts β
.zwo/.ergfiles with live ERG resistance: the trainer holds the exact watts of each step (warm-up, intervals, ramps, cooldown). - Estimates your FTP β a Ramp Test that suggests a functional threshold power from your best 60-second effort, applied with one keystroke.
- Tracks you live β big power/HR readouts, Braille power/cadence/HR/speed graphs, riding zones, and live NP/IF/TSS/kJ while you pedal.
- Keeps everything β per-second samples in local SQLite: session history with drill-down, weekly training load (TSS), and 1m/5m/20m power records.
- Exports to the platforms you already use β saves Garmin-valid
.fitfiles you can drag into Garmin Connect (auto-syncs to Strava), and auto-uploads to Strava on Save when connected (Settings β StravaPKCE,src/strava.rs), queued offline and retried next boot.
| Thing | How it works |
|---|---|
| Smart trainer | Connects over Bluetooth as a standard Fitness Machine; Olympus sets the ERG target power so the trainer never freewheels. |
| Heart-rate strap | Optional second Bluetooth device; its readings win over the trainer's relayed HR whenever both are live. |
| No trainer / no sensors | Everything still works β you just get honest -- stats instead of fabricated numbers. |
While riding (Control panel):
| Key | Action |
|---|---|
Space / Enter |
Pause / resume (clock and distance freeze) |
+ / - |
Nudge the ERG target up / down by 5 W |
e |
Toggle auto-follow vs. hold the current watts |
n / p |
Skip / go back a workout step |
Q |
Finish ride β Save / Discard / Resume |
? |
Keybind reference overlay |
Rider settings (name, weight, height, FTP, max HR) live in data/user/profile.json and are edited under Settings β User.
Every saved ride is written as a Garmin-valid FIT activity to data/.fit/ride_*.fit.
Auto-upload (Phase 12): if Strava is connected (Settings β Strava β STRAVA_CLIENT_ID/STRAVA_CLIENT_SECRET + cargo run --bin strava-auth), Olympus uploads the FIT on Save via POST /api/v3/uploads (src/strava.rs:370); offline files are queued at data/user/strava_queue.json and retried next boot. Disconnect with Enter on the Strava panel (src/app.rs:1077).
Manual: drag the newest file onto Garmin Connect (auto-syncs to Strava/watch):
xdg-open data/.fit # Linux
open data/.fit # macOS
explorer data\.fit # WindowsGarmin Connect then auto-syncs to Strava. Direct Garmin upload isn't planned (no public API); Olympus only talks to Strava when you connect it β otherwise it stays offline.
Linux (BlueZ): btleplug needs the experimental Bluetooth APIs, so start bluetoothd with the -E flag:
sudo systemctl edit bluetooth
# add:
# [Service]
# ExecStart=
# ExecStart=/usr/lib/bluetooth/bluetoothd -E
sudo systemctl restart bluetoothThen bring your adapter up (the app scans on start):
bluetoothctl power on
bluetoothctl scan on # optionalmacOS / Windows: no extra setup β just grant Bluetooth permission the first time the app asks.
Olympus is split into a UI loop (renders the TUI) and an async runtime (talks to hardware), connected by non-blocking message channels β so the interface never blocks real-time data collection.
βββββββββββββββββββββββββββββββββββββββ
β OLYMPUS β
βββββββββββββββββββ¬ββββββββββββββββββββ€
β UI LOOP β ASYNC RUN β
β (Ratatui) β (Tokio Runtime) β
ββββββββββ¬βββββββββ΄βββββββββ¬βββββββββββ€
β STORAGE ENGINE β HARDWARE β
β (SQLite / FIT) β (BLE) β
ββββββββββββββββββββββββββββ΄βββββββββββ
- Storyboard / screens β Main menu, Control (live ride), Database (workouts + session history), Settings, Stats β all in
src/render.rs. - Ride engine & app state β
src/app.rsowns the ride lifecycle, live metrics, and the ERG scheduler. - BLE driver β
src/ble.rsdiscovers, connects, and subscribes to trainer + HR strap, and ramps ERG targets smoothly. - Workout parsing β
src/erg.rsreads.zwo(XML) and.erg(key-value) files and schedules interval targets. - Metrics β
src/math.rscomputes NP / IF / TSS / rolling power / zone time. - Fitness files β
src/fit_writer.rsemits Garmin-valid.fitactivities. - Persistence β
src/data.rsowns the SQLite database and the JSON rider profile. - Strava sync β
src/strava.rshandles PKCE OAuth, token refresh, and queued multipart FIT upload;src/bin/strava-auth.rsis the one-time connect helper.
- Rust β memory-safe, low-latency; comfortably drives ~1 Hz ride metrics and 60 fps rendering.
- Ratatui + Crossterm β immediate-mode terminal UI.
- Tokio β the async runtime;
tokio::sync::mpscshuttles live sensor telemetry to the UI. - btleplug β cross-platform BLE (BlueZ / CoreBluetooth / WinRT), subscribing to the standard Cycling Power, Heart Rate, and Fitness Machine GATT profiles.
- rusqlite β embedded SQLite for session summaries + per-second samples.
- serde β JSON rider profile.
Start the app with no sensors paired and Olympus stays honest: the Control panel shows a plain READY β NO RIDE IN PROGRESS panel (never fake power/HR numbers), and you can still start a ride and save a FIT. Great for testing on an empty laptop.
- End-to-end ride: BLE acquisition (power / cadence, HR / speed) β trainer + optional HR strap, strap-priority HR merge
- ERG target power pushed to the trainer (Fitness Machine Control Point) over live
Idle / Connecting / Connected / Errorconnection states -
.zwo/.ergparsing + interval scheduling (Warmup / SteadyState / IntervalsT / Cooldown / Ramp,β€10 β ΓFTPscaling; multi-minuteRampexpands into a 1-minute ascending staircase for the FTP ramp test) - Metrics: rolling
5/10/20-min(+3m/1m/30s/10s/3s) power, NP/IF/TSS/kJ/kcal, distance - Rider profile (JSON) load/save with a clamped editor
- Garmin-valid FIT writer β
data/.fit/ride_*.fit - SQLite session + per-second
samplespersistence - Database browsing (Workouts + Sessions)
- Settings (Bluetooth / System / User profile)
- Ride lifecycle: pause, summary Save/Discard/Resume, pause-proof stats
Docs as source of truth β
, trust the numbers β
, HR-strap merge β
, FTP ramp test + first-ride onboarding β
, Strava auto-upload (PKCE + queued retry) β
β running as 1.1.0, tag v1.1.0.
- Phase 11 β Distribution & installation: install wizards shipped β
scripts/install.sh(Linux/macOS) +scripts/install.ps1(Windows) β OS/arch detection, SHA-256 verify, data-home seeding, per-OS Bluetooth notes, re-run to update; release script + future Downloads page still open - Phase 12 β Strava auto-upload: OAuth PKCE (
src/strava.rs+src/bin/strava-auth.rs), queued retry onSave, token atdata/user/strava.jsongitignored - Phase 13 β Training depth: workout creator, plans / fitness-freshness, adherence, virtual shifting
- Phase 14 β In-app update check: silent latest-release poll on boot (
src/update.rs) β banner "re-run scripts/install.sh" (Esc/udismiss)
Full phased plan, competitive gap table, and out-of-scope list:
ROADMAP.md