Files
BikeControl/README.md
T
dtourolleandClaude Opus 5 3a2a787b7d Add Svelte GUI, FIT encoder and README
Standalone binary embeds the frontend, avoiding the dev-server dependency
that made the window fail to load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:50:01 +02:00

188 lines
6.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=<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.
---
## 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.