The tag stays the single source of truth — ci-set-version.sh rewrites this key on a tag build and ci-android-version-code.sh derives the versionCode from it. Committing the same values keeps a local or sideloaded build from reporting 0.1.0, and keeps the checked-in number greppable and honest between releases. 1000 + major*10000 + minor*100 + patch puts 0.2.0 at 1200, which is what CI will compute for v0.2.0. The workspace Cargo.toml is deliberately left alone: editing it invalidates Cargo.lock and breaks every --locked build in the same run, to change a number that appears nowhere but the binary's own metadata. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BikeControl
Ride a Van Rysel D100 smart trainer without Zwift. Load a GPX route or a synthetic waveform profile, and the app drives the trainer's resistance to match — recording power, speed and distance as you go.
See REQUIREMENTS.md for the full specification.
Status
| Component | State |
|---|---|
crates/core — physics, profiles, GPX import, ride engine |
Implemented, 103 tests |
crates/ble — FTMS client for the D100, Zwift Click protocol |
Implemented, 108 tests. FTMS and the Click are both wired into the app |
crates/fit — FIT activity encoder |
Implemented, 106 tests. Not yet wired into the app |
crates/probe — hardware discovery CLI |
Works against the real trainer |
src-tauri + ui — desktop app |
Rides the real trainer: scan → connect → control → telemetry |
The app drives the hardware end to end: it scans over BlueZ, connects, acquires FTMS control, feeds Indoor Bike Data into the ride engine, and writes control targets back at 4 Hz. Recording to FIT is the remaining gap.
There is no synthetic rider and no ride-without-a-trainer mode. The ride screen is gated on an FTMS trainer that has accepted the control point, and with no trainer attached the ride reads zero — a session that could be finished without any of it having happened is worse than no session at all.
Prerequisites
- Rust (built with 1.92) and
cargo-tauri:cargo install tauri-cli --version '^2' - Node 20+ and npm
- Linux:
webkit2gtk-4.1,libsoup-3.0, and a running Bluetooth stack (BlueZ)
Running the app
Standalone binary (recommended)
Embeds the frontend, so there is no dev server and nothing to go wrong:
cd src-tauri
cargo tauri build --debug --no-bundle
cd ..
./target/debug/bikecontrol-app
Development mode (hot reload)
cd ui && npm install # required once — skipping this is what causes a black window
cd ../src-tauri
cargo tauri dev
If the window is black and says "Could not connect to localhost: Connection refused", the Vite dev server is not running. Either
npm installwas never run inui/, or something killed the server. Use the standalone binary above, which has no dev server at all.
Keyboard
| Key | Action |
|---|---|
↑ / ↓ |
Gradient up/down (Shift for coarse steps) |
0 |
Reset gradient trim to zero |
Space |
Pause / resume |
M |
Cycle control mode |
L |
Mark lap |
P |
Profile picker |
[ / ] |
Adjust target power or resistance |
? |
Help overlay |
Every shortcut is mirrored by an on-screen control.
Zwift Click
Connect from the device screen — press a button on the pod first, since a Click only advertises while awake. Each button routes to the same intent as the equivalent key, so the two can never drift apart:
| Button | Action |
|---|---|
+ / − |
Shift a gear: ±10 W in ERG, one level in resistance mode, else gradient ±0.5% |
D-pad ↑ / ↓ |
Gradient +0.5% / −0.5% |
D-pad ← / → |
Device screen / ride screen |
A |
Pause / resume |
B |
Insert lap marker |
Y |
Cycle control mode |
Z |
Profiles and routes |
Shifting is emulated app-side. The D100 exposes no virtual-shifting command surface — its Zwift service only streams telemetry (REQUIREMENTS.md §2.3.2) — so a "gear" is a step in whatever target the active control mode drives. The keyboard remains the fallback if a pod's battery dies mid-ride.
Talking to the trainer
The probe CLI is for hardware discovery and diagnosis — it speaks to the trainer
directly, independently of the app.
cargo build -p bikecontrol-probe
./target/debug/probe scan # find fitness machines
./target/debug/probe scan --all --secs 20 # every peripheral
./target/debug/probe inspect --name VANRYSEL # services, characteristics, capabilities
./target/debug/probe monitor --name VANRYSEL # live telemetry: raw hex + decoded
./target/debug/probe set --name VANRYSEL sim=4.0 # apply a target, then auto-reset
./target/debug/probe zwift <ADDR> # Zwift Click: handshake, then log frames
set accepts gradient=<pct>, sim=<pct>, resistance=<level> or power=<watts>, and
always finishes by zeroing the gradient, dropping resistance to minimum and issuing
Reset + Stop — including on Ctrl-C.
The trainer only advertises once awake. Spin the cranks for a few seconds first, or scans will find nothing.
What this trainer reports
Confirmed against the hardware:
VANRYSEL-HT-2876 DECATHLON, model 355194, firmware 0.108
Fitness Machine Service (0x1826)
Zwift custom service (00000001-19ca-4651-86e5-fa29dcdd09d1)
Accepts: SetIndoorBikeSimulationParameters (0x11) <-- use this for gradient
SetTargetResistanceLevel (0x04) range 0..100 step 1
SetTargetPower (0x05) range 50..600 W
Rejects: SetTargetInclination (0x03) not advertised
Notifies: Indoor Bike Data at 4 Hz
Note SetTargetInclination (0x03) is not supported, and its range characteristic
reports only 0–6% with no negatives — so simulation mode is the only usable path for
gradient.
What the Zwift Click v2 reports
Confirmed against the hardware — the pods talk to us unencrypted:
Zwift Click (two pods, one BLE peripheral each)
advertises 0xFC82, manufacturer 0x094a: 0a… and 0b… <-- type byte per pod
service 0xFC82 wraps the familiar Zwift characteristics:
00000002-19ca-… notify device events
00000003-19ca-… write-without-response commands in
00000004-19ca-… read, indicate responses out
00000100/0101/0102-19ca-… undocumented, silent so far
-> 526964654f6e0009 ("RideOn" + 00 09)
<- 526964654f6e0203 ("RideOn" + 02 03) no key exchange, no encryption
<- 191064 battery 100%
<- 2308f7ffffff0f buttons: mask 0xfffffff7, bit 3 held
Two things differ from the public write-ups: the v2 puts everything under 0xFC82 rather
than the trainer's 00000001-19ca-… service, and it reports buttons as a 32-bit
active-low bitmask (0x23) rather than the documented two-varint 0x37 message. Idle is
0xffffffff; a clear bit means pressed.
The bit map is confirmed against the hardware:
| Bit | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|
| Button | left |
up |
right |
down |
A |
B |
Y |
Z |
− |
+ |
D-pad on 0–3, face buttons on 4–7, paddles at 8 and 12; bits 9–11 are unclaimed. Use
probe zwift <ADDR> --buttons to see named presses one line at a time:
[ 3.51s] PRESS #1 A (bit 4) mask 0xffffffef
[ 5.44s] RELEASE --- mask 0xffffffff
[ 6.16s] PRESS #2 Z (bit 7) mask 0xffffff7f
Profiles
Two kinds of ride, both in the same YAML format — see profiles/:
| File | What it does |
|---|---|
sine-overunders.yaml |
Warm-up ramp, then sinusoidal power over-unders |
hill-repeats.yaml |
Distance-based terrain loop, repeats indefinitely |
sawtooth-gradient.yaml |
Gradient sawtooth, distance-based |
square-resistance.yaml |
Raw resistance intervals, bypassing physics |
Blocks are constant, ramp, wave (sine/square/triangle/sawtooth), segments or
terrain, each driving the gradient, resistance or power channel over an extent
measured in seconds or metres:
name: Sine over-unders
blocks:
- { type: ramp, channel: power, from: 100.0, to: 200.0, extent: { seconds: 600.0 } }
- type: wave
channel: power
shape: sine
midpoint: 240.0
amplitude: 40.0
period: { seconds: 120.0 }
repeats: 8.0
looping: false
GPX files are imported directly — testdata/sample-climb.gpx is a 3 km climb with realistic GPS elevation noise. Raw GPS elevation is far too noisy to differentiate into gradients, so import resamples, smooths and clamps before the trainer ever sees a number.
Android
The Android build is the same Rust, the same Svelte, and the same BLE stack —
crates/core, crates/ble and crates/fit contain no platform code (G-4,
NFR-5). Only two things are Android-specific.
BLE. btleplug's Android backend is half Java. btleplug::platform::init
must be handed a JNIEnv from a Java thread before the first scan, so
MainActivity.onCreate calls into src-tauri/src/android.rs to do it. Those
Java classes are not a maven dependency: scripts/sync-android-sources.sh
lifts them out of the btleplug and jni-utils crate sources at exactly the
versions in Cargo.lock, which is what makes a Java/Rust mismatch impossible.
Screen size. See below.
cd src-tauri
cargo tauri android init # regenerates gen/android from scratch
cd ..
./scripts/sync-android-sources.sh # re-applies everything in src-tauri/android/
./scripts/check-android-sources.sh
cd src-tauri && cargo tauri android build --apk --target aarch64
src-tauri/gen/ is generated and untracked. Everything hand-written lives in
src-tauri/android/ — the manifest with the BLE permissions, MainActivity.kt,
the app Gradle script, the ProGuard keep rules, and the theme. Editing a file
under gen/ loses it at the next init; check-android-sources.sh fails the
build if anyone does.
Runtime permissions are split at API 31: BLUETOOTH_SCAN (with
neverForLocation, which we can honestly claim because every scan is filtered
by service UUID) plus BLUETOOTH_CONNECT on Android 12+, and
ACCESS_FINE_LOCATION below it, because that is what a BLE scan legally
required at the time. MainActivity asks on first launch and again on resume.
adb logcat -s BikeControl shows the tracing output — Android has no stdout,
so it is routed through liblog.
Screen size
The UI treats screen size as a measurement, not a set of guesses. Everything
lives in ui/src/lib/viewport.ts: a pure function
from a measurement (width, height, DPR, whether the pointer is coarse) to a
layout plan — type sizes in pixels, column counts, and which sections are worth
their space. viewport.svelte.ts measures and publishes it as CSS custom
properties and data-* attributes on <html>; the stylesheets read those and
never restate a breakpoint of their own.
The rule the plan follows is that a small screen is a content problem, not a scaling one. Readouts have legibility floors in pixels and never go below them; when the space is not there the sparklines go, then the secondary effort numbers, then the detail row — the route chart and the live numbers survive longest. On a phone the control bar's buttons grow to 48 px and the keyboard hints disappear, because there is no keyboard behind them.
That is testable without a device, which is the point:
npm --prefix ui test # asserts the plan for a Pixel 7, a tablet, a desktop…
Building and releasing
CI runs on Gitea (.gitea/workflows/) inside two images built from this repo:
| Image | Dockerfile | Jobs |
|---|---|---|
bikecontrol-builder |
Dockerfile.builder |
tests, clippy, frontend, Linux deb/AppImage, Android APK |
bikecontrol-arch-builder |
Dockerfile.arch |
the .pkg.tar.zst (needs makepkg) |
scripts/build-builder-image.sh --push # both
scripts/build-builder-image.sh --only arch # just the Arch one
The workflows pin these by tag, so a Dockerfile change only reaches CI once the
image has been pushed. Pushing a v* tag builds all three platforms and
publishes a Gitea release; the Android job needs the ANDROID_KEYSTORE_BASE64,
ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD
secrets, without which the APK is debug-signed.
Development
cargo test --workspace # 300+ tests
cargo clippy --workspace --all-targets
npm --prefix ui run check # svelte-check
npm --prefix ui test # vitest — the layout plan
The workspace is deliberately layered so most of it is testable without hardware:
crates/core physics, profiles, GPX, ride state machine — no I/O at all
crates/ble FTMS client; protocol logic is pure functions over bytes
crates/fit FIT encoder; round-trip tested against an independent parser
crates/probe hardware CLI
src-tauri Tauri shell — owns the ride loop
ui Svelte 5 + uPlot — renders snapshots, issues intents
crates/core/src/types.rs is the shared contract between all of them. Change it
deliberately.
The app has one data source: src-tauri/src/session_backend.rs, which wraps the real ride
engine and is fed by live FTMS telemetry. Nothing fabricates rider data.