# 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.
