dtourolleandClaude Opus 5 f2c4cb2120 Gearing as development, physics-derived load model, 105kg rider
Gears are metres per crank revolution rather than gradient offsets, and
resistive_force_n exposes what the road is doing at a given speed so load
can be computed directly instead of servoed.

The load model is tested but not yet commanded: FTMS sim mode has the
trainer compute rolling and aero itself, so sending a gradient that already
contains them would double-count. Needs Crr/Cw zeroed and a ride to verify.

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

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.

S
Description
No description provided
Readme
2.4 MiB
2026-08-21 18:28:11 +00:00
Languages
Rust 81.8%
Svelte 9.6%
TypeScript 4.6%
Shell 2.5%
Kotlin 0.7%
Other 0.8%