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>
188 lines
6.0 KiB
Markdown
188 lines
6.0 KiB
Markdown
# 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 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.
|