dtourolleandClaude Opus 5 9cbc5aa292
🚴 Build and Test BikeControl / Workspace tests (push) Successful in 13m53s
Build & Release / Run tests (push) Successful in 6m40s
🚴 Build and Test BikeControl / Android compile check (push) Successful in 3m41s
Build & Release / Build Linux (deb + AppImage) (push) Successful in 16m44s
Build & Release / Build Arch package (push) Successful in 32m53s
Build & Release / Build Android APK (push) Successful in 21m20s
Build & Release / Create release (push) Successful in 12s
Point the config at 0.2.0, ahead of the tag
The tag stays the single source of truth — ci-set-version.sh rewrites
this key on a tag build and ci-android-version-code.sh derives the
versionCode from it. Committing the same values keeps a local or
sideloaded build from reporting 0.1.0, and keeps the checked-in number
greppable and honest between releases.

1000 + major*10000 + minor*100 + patch puts 0.2.0 at 1200, which is what
CI will compute for v0.2.0. The workspace Cargo.toml is deliberately
left alone: editing it invalidates Cargo.lock and breaks every --locked
build in the same run, to change a number that appears nowhere but the
binary's own metadata.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:15:26 +02:00

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

Embeds the frontend, so there is no dev server and nothing to go wrong:

cd src-tauri
cargo tauri build --debug --no-bundle
cd ..
./target/debug/bikecontrol-app

Development mode (hot reload)

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.

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 06% 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 03, face buttons on 47, paddles at 8 and 12; bits 911 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/:

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:

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

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: 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:

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)
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

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.

S
Description
No description provided
Readme
2.4 MiB
2026-08-21 18:28:11 +00:00
Languages
Rust 81.8%
Svelte 9.6%
TypeScript 4.6%
Shell 2.5%
Kotlin 0.7%
Other 0.8%