Four findings from an evening on the hardware, three of which contradict what this document said. The undocumented `0000010x` characteristics are TI OAD firmware update: their `0x2901` descriptors read "Img Identify", "Img Block" and "OAD Extended Control". The table called them "undocumented, silent so far", which invites probing them to see what answers — and the answer is that `Img Block` takes firmware. They are silent because OAD says nothing until an update is running. Marked do-not-write. A pod offering a public key does not mean it wants encryption. The `−` pod sent a compressed P-256 point in the same session as 304 cleartext button frames. That was read twice as "it has switched to the encrypted path", and neither reading survived the capture it came from. The crypto section now says so, because the mistake is an easy one and it cost an evening. TASK-0(d) is satisfied and RISK-9 is retired. Every button on both pods registers on the bit §2.3.1 documents: D-pad 0-3 and `−` on bit 8 across four cycles, `A`/`B`/`Y`/`Z` on 4-7 and `+` on bit 12 over 83 presses. QZ's `wontfix` for the left `−` does not reproduce here, so OQ-10 needs no answer and gradient control stays on the left pod. The evening the left pod appeared dead was a SAF-9 violation, now §7.1. A half-closed link streams battery and no buttons, which is indistinguishable from broken hardware — and it was diagnosed as a dead pod, a wrong bit map, mis-filed pods and a lapsed unlock before anyone looked at what the previous run had failed to close. Co-Authored-By: Claude Opus 5 <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.