FR-9.17 to FR-9.19 state the screen-size contract: size is measured, the legibility floors are absolute, and touch is a first-class input. NFR-12 to NFR-14 state what the build now guarantees — pinned images, no source that exists only under gen/, and BLE Java locked to the crate. Three corrections to the spec, all of them places where it described a plan the code did not follow: The stack table still named tauri-plugin-blec. The code uses btleplug directly and has since the BLE crate was written; blec wraps it in its own device model, which crates/ble would then have to be written against twice. Recorded with the reason, not silently edited. RISK-1 (btleplug's Android backend is the least mature part of the stack) is partly retired rather than closed: the target builds, the Java is version-locked and the JNI init is symbol-checked in CI. A scan and a connect on real hardware are still unproven, so TASK-4 keeps that half and says exactly what is left — install on a phone, find the D100 and both pods, and survive a screen-off. NFR-11 is honest about being coarser on Android. FLAG_KEEP_SCREEN_ON is held for as long as the app is foregrounded, not scoped to the ride as the desktop inhibitor is. It needs no permission and cannot leak, but it is not what the requirement describes, so the requirement says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
337 lines
13 KiB
Markdown
337 lines
13 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, 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.
|
||
|
||
There is no synthetic rider and no ride-without-a-trainer mode. The ride screen is gated on
|
||
an FTMS trainer that has accepted the control point, and with no trainer attached the ride
|
||
reads zero — a session that could be finished without any of it having happened is worse
|
||
than no session at all.
|
||
|
||
---
|
||
|
||
## 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
|
||
```
|
||
|
||
### 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 <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 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 <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/](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.
|
||
|
||
---
|
||
|
||
## Android
|
||
|
||
The Android build is the same Rust, the same Svelte, and the same BLE stack —
|
||
`crates/core`, `crates/ble` and `crates/fit` contain no platform code (G-4,
|
||
NFR-5). Only two things are Android-specific.
|
||
|
||
**BLE.** btleplug's Android backend is half Java. `btleplug::platform::init`
|
||
must be handed a `JNIEnv` from a Java thread before the first scan, so
|
||
`MainActivity.onCreate` calls into `src-tauri/src/android.rs` to do it. Those
|
||
Java classes are not a maven dependency: `scripts/sync-android-sources.sh`
|
||
lifts them out of the btleplug and jni-utils crate sources at exactly the
|
||
versions in `Cargo.lock`, which is what makes a Java/Rust mismatch impossible.
|
||
|
||
**Screen size.** See below.
|
||
|
||
```bash
|
||
cd src-tauri
|
||
cargo tauri android init # regenerates gen/android from scratch
|
||
cd ..
|
||
./scripts/sync-android-sources.sh # re-applies everything in src-tauri/android/
|
||
./scripts/check-android-sources.sh
|
||
cd src-tauri && cargo tauri android build --apk --target aarch64
|
||
```
|
||
|
||
`src-tauri/gen/` is generated and untracked. Everything hand-written lives in
|
||
`src-tauri/android/` — the manifest with the BLE permissions, `MainActivity.kt`,
|
||
the app Gradle script, the ProGuard keep rules, and the theme. Editing a file
|
||
under `gen/` loses it at the next `init`; `check-android-sources.sh` fails the
|
||
build if anyone does.
|
||
|
||
Runtime permissions are split at API 31: `BLUETOOTH_SCAN` (with
|
||
`neverForLocation`, which we can honestly claim because every scan is filtered
|
||
by service UUID) plus `BLUETOOTH_CONNECT` on Android 12+, and
|
||
`ACCESS_FINE_LOCATION` below it, because that is what a BLE scan legally
|
||
required at the time. `MainActivity` asks on first launch and again on resume.
|
||
|
||
`adb logcat -s BikeControl` shows the `tracing` output — Android has no stdout,
|
||
so it is routed through liblog.
|
||
|
||
---
|
||
|
||
## Screen size
|
||
|
||
The UI treats screen size as a measurement, not a set of guesses. Everything
|
||
lives in [`ui/src/lib/viewport.ts`](ui/src/lib/viewport.ts): a pure function
|
||
from a measurement (width, height, DPR, whether the pointer is coarse) to a
|
||
layout plan — type sizes in pixels, column counts, and which sections are worth
|
||
their space. `viewport.svelte.ts` measures and publishes it as CSS custom
|
||
properties and `data-*` attributes on `<html>`; the stylesheets read those and
|
||
never restate a breakpoint of their own.
|
||
|
||
The rule the plan follows is that a small screen is a **content** problem, not
|
||
a scaling one. Readouts have legibility floors in pixels and never go below
|
||
them; when the space is not there the sparklines go, then the secondary effort
|
||
numbers, then the detail row — the route chart and the live numbers survive
|
||
longest. On a phone the control bar's buttons grow to 48 px and the keyboard
|
||
hints disappear, because there is no keyboard behind them.
|
||
|
||
That is testable without a device, which is the point:
|
||
|
||
```bash
|
||
npm --prefix ui test # asserts the plan for a Pixel 7, a tablet, a desktop…
|
||
```
|
||
|
||
---
|
||
|
||
## Building and releasing
|
||
|
||
CI runs on Gitea (`.gitea/workflows/`) inside two images built from this repo:
|
||
|
||
| Image | Dockerfile | Jobs |
|
||
|---|---|---|
|
||
| `bikecontrol-builder` | `Dockerfile.builder` | tests, clippy, frontend, Linux deb/AppImage, Android APK |
|
||
| `bikecontrol-arch-builder` | `Dockerfile.arch` | the `.pkg.tar.zst` (needs `makepkg`) |
|
||
|
||
```bash
|
||
scripts/build-builder-image.sh --push # both
|
||
scripts/build-builder-image.sh --only arch # just the Arch one
|
||
```
|
||
|
||
The workflows pin these by tag, so a Dockerfile change only reaches CI once the
|
||
image has been pushed. Pushing a `v*` tag builds all three platforms and
|
||
publishes a Gitea release; the Android job needs the `ANDROID_KEYSTORE_BASE64`,
|
||
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS` and `ANDROID_KEY_PASSWORD`
|
||
secrets, without which the APK is debug-signed.
|
||
|
||
---
|
||
|
||
## Development
|
||
|
||
```bash
|
||
cargo test --workspace # 300+ tests
|
||
cargo clippy --workspace --all-targets
|
||
npm --prefix ui run check # svelte-check
|
||
npm --prefix ui test # vitest — the layout plan
|
||
```
|
||
|
||
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 has one data source: `src-tauri/src/session_backend.rs`, which wraps the real ride
|
||
engine and is fed by live FTMS telemetry. Nothing fabricates rider data.
|