# 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](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 ### Standalone binary (recommended) Embeds the frontend, so there is no dev server and nothing to go wrong: ```bash 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: ```bash BIKECONTROL_DEMO=1 ./target/debug/bikecontrol-app ``` ### Development mode (hot reload) ```bash 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. ```bash 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 # Zwift Click: handshake, then log frames ``` `set` accepts `gradient=`, `sim=`, `resistance=` or `power=`, 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 --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/](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`: ```yaml 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](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 ```bash 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.