# 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 | Implemented, 96 tests. **Not yet 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 | Runs on a **mock rider**, not the real trainer | The GUI and the trainer are both working, but **not yet connected to each other** — the app ticks a synthetic rider while `probe` talks to the real hardware. Joining them is the next step. --- ## 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. --- ## 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 ``` `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. --- ## 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.