dtourolleandClaude Opus 5 db974dcac1
🚴 Build and Test BikeControl / Workspace tests (push) Successful in 13m33s
🚴 Build and Test BikeControl / Android compile check (push) Successful in 3m29s
Pin the versionCode in the config, not in a file the build overwrites
The Android job died on `tauri.properties not found`. It is not found
because `cargo tauri android init` does not write it: in a clean clone
init leaves gen/android/app/ holding build.gradle.kts, proguard-rules.pro
and src/, nothing else. The generated app/.gitignore names the pattern —
tauri.properties sits with tauri.build.gradle.kts and proguard-tauri.pro,
the tauri.* files that `android build` stamps out on every run.

So the step was wrong twice and the crash was the lucky half. Had the
file existed, the build would have rewritten it from tauri.conf.json and
discarded the sed — a green build shipping the default versionCode, which
is the failure that reaches a rider's phone rather than the log.

Tauri exposes the actual input, so use it:

- tauri.conf.json gains bundle.android.versionCode, committed rather than
  conjured by CI, so the key is greppable and the sed has a fixed target.
- ci-android-version-code.sh edits the config. Formula and the 1000 floor
  are unchanged; the header comment is rewritten, since its premise (init
  writes the file, the default collides) does not hold — Tauri's default
  is major*1000000 + minor*1000 + patch, monotonic, and it puts 0.1.0 at
  exactly 1000, which is where the floor comes from. Missing key or failed
  substitution now exits 1 instead of degrading to a silent no-op.
- The step moves ahead of `android init`, next to ci-set-version.sh, since
  both edit the same config.

Verified against a clean clone: 0.1.0 -> 1100, v0.2.3 -> 1203,
v1.0.0 -> 11000, config still parses at each step. Then a real
`cargo tauri android build`, which wrote versionCode=1100 into
tauri.properties — the value reaches the APK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 17:39:06 +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%