dtourolleandClaude Opus 5 7a4e2be65e Measure the screen, then decide what fits on it
The ride screen was built for a 1440x900 window and expressed its type
scale in vw. On a phone that fails twice over: 7vw of a 412px viewport is
29px, well under what is readable from the bars, and five side-by-side
readouts do not fit across 412px at any type size. Shrinking is not the
answer to a small screen — showing less is (FR-9.17, FR-9.18).

So screen size becomes a measured input. viewport.ts is 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
earn their space. viewport.svelte.ts measures and publishes it as CSS
custom properties and data-* attributes; the stylesheets read those. The
three max-width media queries are gone, so there is now exactly one
definition of "narrow" in the codebase rather than four that can disagree
about where a phone starts.

The sizes are absolute rather than relative, and that is a physical
argument, not a preference. A number has to subtend enough visual angle
to read from the riding position. Desktop is ~96 CSS px per inch at about
a metre; Android's CSS pixel is the dp, ~160 per inch, and a bar-mounted
phone sits at roughly 0.6 m. (160/96) x (0.6/1.0) is almost exactly 1, so
the same pixel size is about as readable in both places — which is why
the floors are plain numbers with no per-platform correction, and why a
small screen is a content problem.

What gets dropped, and in what order: anything the rider cannot act on
mid-ride goes before anything they can. Sparklines first — they are
history, and a 60px chart is a smear. Then average / normalised / work /
burned, which is what the summary screen is for. The detail row survives
longer, because "climbing left" is the question a rider on a hill is
actually asking, and elapsed time stays on a phone while covered and
ascended go. The route profile is the screen's whole point (FR-9.7) and
goes only in landscape on a phone, where keeping it would leave nothing
for the numbers.

Touch is treated as an input, not a narrower mouse (FR-9.19): 48px
targets, hover styling suppressed so it does not stick after a tap, and
keyboard hints hidden — with a word added to the help button, which
carried only a key cap and would otherwise have become unpressable.

Being a pure function is the point: "does this fit on a Pixel 7" is now
answerable in CI on a machine with no phone attached. 15 tests, run by
`npm --prefix ui test`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:53:00 +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.


Development

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