Files
BikeControl/README.md
T
dtourolleandClaude Opus 5 57eb5e809b Virtual gearing, trainer-speed blend, and cadence decode
Gears are expressed as an offset to the commanded gradient, leaving the
physics on the route's true gradient so shifting changes effort, not speed.
Neutral gear commands exactly the route gradient, so an un-shifted ride is
unchanged.

Cadence is not in FTMS on this trainer but is on its Zwift channel, decoded
against captured frames. The undeclared FTMS trailing bytes were ruled out:
wheel RPM restated at a fixed 73.8x speed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:33:28 +02:00

9.1 KiB
Raw Blame History

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.

The synthetic rider is still available for GUI work with no hardware on the desk — BIKECONTROL_MOCK=1, or BIKECONTROL_DEMO=1 which also loads the sample route and starts riding. Both need the mock-ride feature, which is on by default; --no-default-features produces a binary that can only ever show real trainer data.


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

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

Open straight onto a running ride with the sample GPX loaded:

BIKECONTROL_DEMO=1 ./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 install was never run in ui/, 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 06% 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 03, face buttons on 47, paddles at 8 and 12; bits 911 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.


Development

cargo test --workspace          # 300+ tests
cargo clippy --workspace --all-targets
cd ui && npm run check          # svelte-check

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 selects its data source by Cargo feature: mock-ride (default) drives a synthetic rider; real-session wraps the real ride engine and needs a live telemetry source.