A local Windows dashboard for Echelon Connect bikes. It speaks the Echelon proprietary Bluetooth-LE protocol directly — no Zwift, no Echelon subscription, no cloud, no phone. Everything runs on your laptop and every ride is saved to disk.
Before you start, you need: a Windows PC with Bluetooth, and an Echelon bike whose
Bluetooth name starts with ECH (see Compatibility). Do not pair
the bike in Windows Bluetooth settings — BikeStation connects on its own, and pairing it
in Windows can stop it working.
That one file is the whole program. Nothing else to install.
Drag it out of Downloads into its own folder, such as Documents\BikeStation. It saves
your rides and settings automatically, so avoid Program Files, which Windows keeps
read-only.
Windows shows a blue "Windows protected your PC" box. This appears for any program without a paid commercial signing certificate — it does not mean anything is wrong.
Click More info, then Run anyway.
Double-click BikeStation.exe. Nothing seems to happen for a second or two, then your web
browser opens by itself showing the dashboard. That browser page is the app.
To stop it later, open Task Manager (Ctrl+Shift+Esc), find BikeStation, and press
End task. Closing the browser tab alone leaves it running in the background.
- Pedal the bike once to wake it up, and fully close the Echelon phone app — the bike allows only one connection at a time.
- In the dashboard, click SETUP, then SCAN.
- Click BIKE next to the device whose name starts with
ECH. - Type in your weight, FTP and max heart rate so the numbers are calculated for you.
Done. Your bike is remembered and reconnects by itself next time.
Hold Shift, right-click the folder your BikeStation.exe is in, choose
Open PowerShell window here, and run:
.\BikeStation.exe --demo
That runs the full dashboard on simulated ride data, with no bike and no Bluetooth.
Echelon Connect bikes share one proprietary GATT protocol, and BikeStation targets the
protocol rather than any single model. Nothing about the bike is hardcoded: devices are
matched on the ECH… advertising prefix, and the resistance range is queried from the
bike (F0 A3) rather than assumed, so a bike reporting 1–24 works the same as one
reporting 1–32.
Verified end to end on a Connect EX-5. Other Connect models — Sport, EX-3, EX-5s and relatives — advertise the same service and frame format and are expected to work, but I have only one bike to test against. Reports welcome either way.
Two things genuinely vary between models, and both degrade gracefully:
- Resistance writing. Reading telemetry works on any bike that speaks the protocol.
Setting resistance (
F0 B1) needs motorised resistance to act on — that drives the slider, ERG mode, structured workouts and knob sensitivity. On a bike whose knob is purely mechanical the metrics still work and those controls simply will not move it. - Power figures. No Echelon bike has a power meter, so watts are estimated from a resistance/cadence table calibrated on a Connect Sport. Expect the numbers to be self-consistent rather than absolutely accurate, and more approximate the further your model sits from that calibration. Two tables ship; switch them in SETUP.
Recent firmware on some bikes refuses to stream to third-party apps until the official
Echelon app has unlocked it (frame F0 E0); see Troubleshooting.
The protocol was worked out by the community, not by me. BikeStation builds on qdomyos-zwift, the echelon-bike firmware clone, echbt and SmartSpin2k — see Licence.
Only needed if you want to change the code — the download above is self-contained.
Requires Python 3.10 or newer (both bleak and aiohttp need it).
BikeStation.bat
That installs bleak + aiohttp on first launch, starts the server and opens
http://127.0.0.1:8730/. Useful flags:
| command | what it does |
|---|---|
BikeStation.bat |
normal ride mode |
BikeStation.bat --demo |
synthetic bike firmware — full UI, no Bluetooth |
BikeStation.bat --scan |
list BLE devices and exit (find your bike's address) |
BikeStation.bat --port 9000 |
different HTTP port |
BikeStation.bat --no-browser |
don't open a browser |
Connecting the bike is the same as for the executable — see step 5 above. If you own a Bluetooth HR strap, click STRAP next to it in SETUP: it is more accurate than the bike's grip sensors and also gives RR intervals.
Users without Python can run a single bundled BikeStation.exe:
pip install pyinstaller
python -m PyInstaller bikestation.spec --noconfirm --clean
The result lands in dist/BikeStation.exe (~12 MB, no Python install needed). Two details
the spec exists to handle:
- the dashboard's HTML/CSS/JS/fonts are read at runtime, so
bikestation/webis bundled and resolved throughbikestation/paths.pyrather than assumed to sit next to the source; bleakreaches Windows Bluetooth through dynamically importedwinrt.*modules, which static analysis cannot see, so they are collected explicitly.
The executable is a single portable file that people move, copy to another machine, or drop
somewhere unwritable such as Program Files. So a frozen build does not keep data beside
the .exe; it uses a fixed per-user directory instead:
%LOCALAPPDATA%\BikeStation e.g. C:\Users\<you>\AppData\Local\BikeStation
Move or rename the executable as much as you like — it always finds the same rides, settings
and class library. %TEMP% is deliberately not used, because Storage Sense and Disk Cleanup
empty it and your ride history would disappear. Running from a source checkout still uses
data/ inside the repo, which keeps a clone self-contained.
--data-dir <path> overrides the choice either way, and DIAGNOSTICS shows the resolved
location as data folder so you can always find your rides.
Read from the bike (it only transmits these four things):
- cadence (rpm, 16-bit)
- cumulative crank revolutions
- resistance level and the bike's real resistance range (queried from the bike, commonly 1–32)
- heart rate, when a strap is paired to the bike itself
- plus model id / hardware / firmware version from the device-info frame
Derived locally, at 1 Hz:
- power — the bike has no power meter; watts come from the calibrated
33 × 11 resistance/cadence table (switchable
default/mgarceain SETUP) - speed (console-matching
0.375 km/h per rpm, or a road-load model from power), distance, kJ of work, kcal - average and maximum power, cadence, speed, resistance, heart rate
- normalised power, intensity factor, TSS, W/kg
- time in each of the 7 power zones and 5 heart-rate zones
- Peloton-equivalent resistance
- laps, auto-pause, and a per-second raw log
The RIDE tab is split in two so everything fits on one screen without scrolling: GAUGES (watts / rpm / resistance rings, session clock, the live strip and the control panel) and TELEMETRY (power analytics, the chart, zone breakdown and laps). The chosen sub-tab is remembered between sessions.
- resistance −/+ and slider (writes
F0 B1to the bike) - Knob sensitivity (SETUP) — the bike's knob moves one resistance level per quarter
turn, so covering the full 1–32 range takes roughly eight turns. Set a multiplier of
1–10× and BikeStation watches the
F0 D2level reports, multiplies each knob movement, and writes the result back, so fewer rotations cover the range. 1× leaves the knob untouched. Higher values are coarser: at 4× a quarter turn moves 4 levels. Requires a bike that accepts resistance writes. - ERG mode — pick a wattage; resistance is re-trimmed every 2 s as your cadence drifts so the power target holds
- Workouts — structured interval sessions from
data/workouts/*.json(warm-up, sweet spot 3×12, VO2max 5×4, 20-min FTP test, endurance). Segments are absolute watts,% FTP, or a fixed resistance level; each segment change starts a lap. - bike control-state frames (
stream on / pause / stop) - Keep awake (SETUP) — Windows sleeps on input idleness, and pedalling is not input,
so a ride would otherwise be cut off mid-class. While enabled BikeStation holds off both
system and display sleep (
SetThreadExecutionState) and releases it on exit. On by default; untick it in SETUP to leave the normal power timers alone. DIAGNOSTICS shows the live state askeep awake. Windows only — a no-op elsewhere, with a warning in the log. - keyboard:
↑/↓resistance,←/→gauges / telemetry,Spacepause,Llap,Sstart,Ffocus mode,1/2/3manual / erg / workout
The CLASS tab embeds a YouTube class next to a live metric rail, so one window covers
both the video and the ride. Paste a link (watch, youtu.be, /live/, /shorts/, or a
bare video id — ?t= timestamps are honoured) and hit LOAD.
HIDE METRICS gives the video the full width.
LIBRARY holds classes and whole playlists, stored server-side in data/classes.json so
they survive a browser change and land in your backups next to rides and settings. Paste a
playlist link and press ADD TO LIBRARY and every video in it is resolved with titles and
durations — no Google API key needed. Playlists expand and collapse, ↻ re-fetches one after
the channel adds classes, and × removes a playlist or a pinned class. A single video link
pins just that class.
Chapter laps. Many structured classes ship YouTube chapters (warm-up / intervals / cool down). When a class loads, its chapters are fetched and the current one shows in the rail; crossing into a new chapter starts a lap, so the lap table lines up with the class segments. Arriving into the chapter that is already playing does not start a lap, and neither does re-crossing one you already passed. Videos without chapters simply do nothing. Turn it off with the chapter laps checkbox.
With sync ride on, pressing play starts or resumes the ride and pausing the video pauses it — the timer, recording, and laps follow the class instead of needing separate clicks. The rail carries power, cadence, resistance, heart rate, the session clock, the current chapter, a resistance slider, a rolling power sparkline with an FTP reference line, and START/PAUSE/LAP.
Playback uses a plain embed iframe and deliberately does not depend on YouTube's
iframe_api script, which ad blockers routinely filter — if that script is blocked you lose
ride sync, not the video, and a status line says so. DIAGNOSE probes www.youtube.com,
i.ytimg.com and the API script and reports which of them this browser can actually reach,
which separates an extension or DNS blocker from a genuine network problem.
Videos whose uploader disables embedding cannot play here — BikeStation says so and offers a link to open it on YouTube. Note that while the video has keyboard focus, YouTube swallows key presses, so use the on-screen −/+ or slider for resistance rather than the arrow keys.
The data root is data/ in a source checkout, or %LOCALAPPDATA%\BikeStation for the
executable — DIAGNOSTICS shows the resolved path as data folder.
settings.json rider profile + last device
classes.json pinned classes + saved YouTube playlists
workouts/*.json editable interval workouts
rides/ride-*.csv every second of the ride
rides/ride-*.tcx import into Strava / Garmin / intervals.icu / TrainingPeaks
rides/ride-*.json summary incl. laps and zone distribution
A ride starts automatically when you begin pedalling and auto-pauses when you stop; FINISH writes the files. HISTORY lists past rides with download links.
Nothing found when scanning. Pedal once to wake the bike. Close the Echelon phone app completely — the bike accepts a single connection at a time. Do not pair the bike in Windows Bluetooth settings; BikeStation connects without pairing.
Connects but all metrics stay 0 / "BIKE LOCKED". Recent Echelon firmware refuses to
stream to third-party apps until the official app has unlocked the bike (frame F0 E0,
see qdomyos-zwift issues #4750
and #4868). Open the Echelon
app once, start and end a free ride, force-close the app, then reconnect here.
Link drops mid-ride. BikeStation reconnects automatically with backoff, replays the handshake and re-applies your resistance level (the bike forgets it on disconnect). If it keeps failing, power-cycle the bike.
Wattage looks wrong. It is a table estimate, not a measurement. Try the mgarcea
table in SETUP, which reads lower at high resistance.
Diagnostics tab shows the raw hex frames, per-opcode counters, checksum errors, the full GATT tree and the event log — that is the first place to look if anything misbehaves.
Service 0bf669f1-45f2-11e7-9598-0800200c9a66; write …f2, notify …f3 (command
responses) and …f4 (telemetry). Frames are F0 | opcode | length | payload | checksum,
checksum = low byte of the sum of the preceding bytes.
| direction | frame | meaning |
|---|---|---|
| → | F0 A1 00 91 |
get device info (model, hw, fw) |
| → | F0 A3 00 93 |
get resistance range (reply max,min) |
| → | F0 A5 00 95 |
get resistance level |
| → | F0 B0 01 01 A2 |
control state: 1 = start streaming, 2 = pause, 0 = stop |
| → | F0 B1 01 <lvl> |
set resistance |
| → | F0 A0 01 <ctr> |
keepalive, sent every 2 s |
| ← | F0 D1 09 … |
telemetry: [3:5] timestamp, [5:9] revolutions, [9:11] rpm, [11] heart rate |
| ← | F0 D2 01 <lvl> |
resistance changed (only sent on change) |
| ← | F0 E0 … |
bike locked |
Note that qdomyos-zwift decodes [7:9] of the telemetry frame as distance and reads
cadence from byte 10 only; per the bike-firmware clone those bytes are the low half of
the 32-bit revolution counter and the low byte of a 16-bit rpm. BikeStation uses the
firmware layout, which is also why it can surface heart rate and revolutions.
Not affiliated with, endorsed by, or supported by Echelon Fitness. "Echelon" and product names are trademarks of their respective owners. This project talks to the bike over an undocumented protocol worked out by the community; it can stop working whenever Echelon ships new firmware, and using it may affect your warranty or your terms of service with Echelon. It is provided with no warranty of any kind — see the licence.
It also writes resistance changes to a machine you are pedalling. Resistance can change under your legs with no warning, particularly in ERG mode, during a structured workout, or with knob sensitivity above 1×. Do not use it unattended, and stop pedalling before experimenting with the controls.
Ride data stays on your machine. Nothing is uploaded anywhere, and there is no telemetry.
data/ is git-ignored because it holds your rider profile and every ride you have recorded.
Copyright (C) 2026 Nathan Brittin.
GPL-3.0-or-later. The full text is in LICENSE.
That choice is deliberate rather than a default. The Echelon protocol here was worked out by reading prior community reverse-engineering, and the strictest of those projects is GPL-3.0, so releasing under the same terms is safe whether this counts as an independent reimplementation or a derivative work:
| project | licence |
|---|---|
| qdomyos-zwift | GPL-3.0 |
| SmartSpin2k | GPL-2.0 |
| echelon-bike | no licence file — all rights reserved by default |
| echbt | verify before reusing anything from it |
Frame layouts and UUIDs are functional facts rather than creative expression, so documenting
them is not itself derivation — but no code should be copied out of echelon-bike, which
carries no licence and is therefore all rights reserved.
If you distribute BikeStation.exe, GPL-3.0 obliges you to make the corresponding source
available to whoever receives it. Attaching the binary to a GitHub release of this public
repository satisfies that, because the matching source sits in the same place. Bundled
dependencies are compatible: bleak (MIT), aiohttp (Apache-2.0), the winrt projections
(MIT) and CPython itself (PSF) may all be combined with GPL-3.0 code.



