Files
BikeControl/REQUIREMENTS.md
T
dtourolleandClaude Opus 5 7b511db3dc Ride the drivetrain, command the load in watts
Speed now comes from the drivetrain and the load from the road, which is
the way round a bike actually works.

Speed is cadence x development, filtered lightly. Power, not cadence,
decides whether the rider is driving it: on a direct-drive trainer the
flywheel keeps the cranks turning after they stop, so cadence alone reads
a healthy 80 rpm for someone doing nothing. Below 15 W the speed runs
down to whatever the gradient sustains on no power - zero uphill, a real
freewheeling speed on a descent. Stopping on a 3.5% climb used to settle
at 22 km/h and stay there, because the model wanted to decelerate and a
blend toward the flywheel speed outvoted it; that blend is gone.

The D100 sends no cadence over FTMS - it is a rebadged Magene T110 with
cadence disabled in firmware (qdomyos-zwift#3282) - so it is inferred
from wheel speed, which one sprocket and no freewheel make exact. Its
Zwift channel does carry cadence, and is now greeted with RideOn and
subscribed on every notifying characteristic, so a measured value is used
where one arrives.

The load is commanded as power, not gradient. The trainer declares
50-600 W in 1 W steps against 0-6% inclination in 0.1% steps refusing
negatives, and whether it acts on 0x11 at all is still unconfirmed. Its
power target is a ceiling rather than a setpoint, which is very nearly
what a road is: exceed it and the surplus becomes speed. Gravity travels
on the same channel as watts, so nothing is lost by leaving 0x11 alone.
LoadChannel keeps the gradient path selectable and tested.

Virtual shifting reaches the trainer for the first time. The physics
load model was written but never called, and a paddle press both shifted
a gear in Rust and nudged the gradient in the webview - the shift
silently, the tilt visibly, so the paddles looked like a gradient trim.

Also: a fixed 12 W drivetrain loss, held as a power because that is how
it presents; crank length, so a gear can be reported as the force it puts
under the foot; gear and pedal force on the ride screen; a drag-race
profile for testing gearing on the flat.

Two readout bugs fixed on the way. The rolling windows were trimmed by
timestamp but fed on a fixed timer, so every second spent on the ride
screen before starting pushed samples at t=0 that could never expire -
speed read a fraction of the truth for the first 45 s. And the headline
speed was a 45 s mean, which took most of a minute to show a gear change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:21:08 +02:00

47 KiB
Raw Blame History

BikeControl — Requirements Specification

Status: Draft v0.4 Date: 2026-08-05 Target hardware: Van Rysel D100 trainer + Zwift Cog + Zwift Click v2

v0.4 changes — the input decision is reversed. Further research found that the Click v2 "unlock" is performed by the rider in the free Zwift app, not by the consuming application, and that it then works with any third-party app for ~24 h (§2.3). Combined with three open-source implementations of the Zwift protocol — one GPL-3.0, one MIT (§3.43.6) — the app talks to the Click v2 directly over BLE. v0.3's OpenBikeControl bridge is dropped: there is no fallback input path. New risk: the left pod, where third-party support is weakest, now carries all gradient and route control with nothing behind it (RISK-9).


1. Overview

BikeControl is a desktop-first (Android-later) application that connects to a Van Rysel D100 smart trainer and a handlebar controller over Bluetooth Low Energy, and provides:

  • Virtual shifting on the single-cog drivetrain, via the Click's shift paddles.
  • Handlebar gradient control, via the Click's D-pad.
  • Gradient-adaptive routes driven from GPX or hand-authored profiles.
  • Synthetic waveform profiles — sine, square, ramp — applied to resistance, gradient or target power.
  • Ride recording exported as a FIT file.
  • A GUI for connection management and live telemetry visualisation.

1.1 Goals

ID Goal
G-1 Ride a gradient profile end-to-end without touching the computer once started
G-2 Control gearing and gradient entirely from the bars
G-3 Produce a FIT file that imports cleanly into Strava / Garmin Connect
G-4 Run on Linux desktop today; port to Android without rewriting the core
G-5 Fail safe — never leave the trainer at high resistance after a fault
G-6 Prefer documented, openly-licensed protocols; keep any proprietary protocol handling isolated and replaceable

1.2 Non-goals

  • Multiplayer, social features, or any network service.
  • 3D world rendering or avatars.
  • ANT+ support (§10.1).
  • Implementing the Click v2 unlock ourselves. The rider performs it in the free Zwift app (§2.3); we only need to speak the protocol afterwards.
  • Acting as a bridge for other training apps — that is what the existing BikeControl app does, and this project does not duplicate it.
  • Any secondary input protocol. The Click v2 client is the only input path.

2. Hardware & Operating Assumptions

2.1 Van Rysel D100

FTMS support confirmed by a working MIT-licensed implementation built specifically for this trainer (§3.2).

Property Value Confidence
Bluetooth profile FTMS (0x1826) Confirmed
Target power (0x05) Supported; reference clamps 100600 W Confirmed
Target resistance (0x04) Supported, sint16; reference caps at 100 Confirmed
Target inclination (0x03) Implemented in the reference as sint16 Confirmed
Sim mode (0x11) Not used by the reference Unconfirmed — TASK-1
Concurrent BLE hosts Assume one Assumed

A-1: The reference drives grade via SetTargetInclination (0x03), not SetIndoorBikeSimulationParameters (0x11). TASK-1 resolves whether 0x11 works. Low impact either way, because the app owns the physics (FR-7.1).

2.1.1 Cadence is on the Zwift channel, not FTMS (measured 2026-08-05)

The D100's Fitness Machine Feature bits (014000000ca00000) advertise average speed and power only — no cadence — and that is accurate for FTMS. The ten undeclared trailing bytes in its Indoor Bike Data were investigated and carry no cadence either: the only varying field is a fixed 73.8x the declared speed (wheel RPM restated), constant to 0.5% from 1.7 to 29.7 km/h.

Cadence is nevertheless available, on the trainer's Zwift service. Message type 0x03 at ~1 Hz, five protobuf varints, of which two are independent:

field meaning note
1 instantaneous power, W verified: ~120 W then ~350 W across two effort phases
2 heart rate always 0 with no strap paired
3 cadence, 0.1 rpm verified: 620 -> 62.0 rpm, 1030 -> 103.0 rpm against held cadences
4 duplicate of field 1 ratio exactly 1.00 on every frame
5 speed a fixed 28.6x field 3

Field 5 being a constant multiple of field 3 is the signature of the Cog: one sprocket means cadence and wheel speed are mechanically locked, so the ratio is the gearing. Observing that constant is what identifies the pair.

Decoded by bikecontrol_ble::zwift::decode_riding_data, tested against captured frames. This unlocks torque-based virtual gearing (tau = P/omega), replacing the provisional gradient-offset table in crates/core/src/gearing.rs.

2.2 Zwift Cog

A single 14T cog — no mechanical gears. This is why virtual shifting (§5.4) is a core requirement, not a nicety: without it the rider has exactly one gear.

2.3 Zwift Click v2 ⚠

The unit is a v2 (two pods: navigation D-pad on the left, lettered face buttons on the right, a shift paddle under each). This is the harder variant, and the difference is not merely one of degree.

The unlock is performed by the rider, not by the app. This is the key finding of v0.4 and it changes everything. The Click v2's encryption context times out roughly a minute after it leaves a Zwift session; refreshing it requires the official Zwift app. But once refreshed, the device works with any third-party app for ~24 hours.

The rider's procedure — free, no paid subscription required:

  1. Open the Zwift app (desktop or mobile) and log in.
  2. Go to the device pairing screen; pair the Click.
  3. Keep it connected 1030 seconds while pressing a button.
  4. Close Zwift completely.

A-2: BikeControl 6.0.0 (June 2026) added a keep-alive that removes the daily unlock entirely — but only for the right controller, only for paid Pro users, and the implementation lives in a private submodule. We therefore assume the manual daily unlock. Replicating the keep-alive is explicitly out of scope; see §1.2.

2.3.1 Zwift BLE protocol TASK-0 answered

Originally established from three open-source implementations (§3.43.6), then confirmed against our own pods on 2026-08-05 with probe zwift. Where the two disagree, the hardware wins and the difference is called out below.

Item Value Confidence
Service on the Click v2 0000FC82-0000-1000-8000-00805F9B34FB Confirmed
Service on the D100 trainer 00000001-19CA-4651-86E5-FA29DCDD09D1 Confirmed
Async (notify) 00000002-19CA-… — device events Confirmed
Sync RX (write-without-response) 00000003-19CA-… — commands to device Confirmed
Sync TX (read/indicate) 00000004-19CA-… — responses Confirmed
00000100-…, 00000101-… (write/notify), 00000102-… (write-without-response/notify) undocumented, silent so far Confirmed present
00000006-… absent on our v2
Manufacturer ID 2378 (0x094A) Confirmed
Device type byte 0x0A and 0x0Bone per pod, not a version marker Confirmed
Handshake Write 52 69 64 65 4F 6E 00 09 to Sync RX Confirmed
Handshake reply 52 69 64 65 4F 6E 02 03 on Sync TX — 02 03, not the documented 01 03 Confirmed
Button state 0x23 + one protobuf varint: a 32-bit active-low bitmask Confirmed
Battery 0x19; 19 10 64 = 100% Confirmed
0x37 two-varint button message never seen on our v2 — presumably v1 Superseded

The two services are not interchangeable. The Click v2 wraps the same …-19CA-… characteristics in the SIG-assigned service 0xFC82; the trainer uses the original custom service UUID. Discovery must accept either.

Button bitmask. Idle is 0xFFFFFFFF; a clear bit means pressed, and the pod streams at roughly 10 Hz while anything is held. Two bits can be clear at once, so it is live state, not an event code.

Bit map — confirmed 2026-08-05. Established by pressing each button in a known order, reproduced across two full runs plus targeted spot checks:

Bit Button Mask when held Bit Button Mask when held
0 left 0xFFFFFFFE 5 B 0xFFFFFFDF
1 up 0xFFFFFFFD 6 Y 0xFFFFFFBF
2 right 0xFFFFFFFB 7 Z 0xFFFFFF7F
3 down 0xFFFFFFF7 8 paddle 0xFFFFFEFF
4 A 0xFFFFFFEF 12 + paddle 0xFFFFEFFF

D-pad on 03, face buttons on 47, paddles at 8 and 12. Bits 911 are unclaimed — no button we have found drives them.

A and Z disagreed between the first two captures and were settled by an alternating two-button test returning 4, 7, 4, 7, 4, 7. The first capture had been pressed out of order.

The pods are not disjoint — answered 2026-08-05. All ten buttons arrived over the single pod at f4:c4:59:03:a1:8e (type byte 0x0B), and with both pods connected the + paddle arrives twice: once from the pod it is printed on and once relayed by its twin. arrives once. Measured from a ride log rather than a capture — one press of + moved the gear by two (7 → 9 → 11), one press of by one.

Two consequences, both handled in controller::Buttons. The app must merge the pair into one controller: a button is held when either pod says so, and only that aggregate's edges may act, or every + press shifts twice. And a pod sending its twin's paddle is normal, so it is evidence of a wrong-way-round pair only while that pod has never sent its own — otherwise the connection screen asks the rider to swap a pair that is filed correctly.

2.3.2 The D100's own Zwift service — telemetry, not shifting

The trainer answers the same handshake (RideOn 00 09RideOn 02 00) on the original 00000001-19CA-… service, and then streams a 0x03 message at ~1 Hz:

Field Pedalling Coasting Reading
1 177 → 180 → 120 6 Instantaneous power, watts
2 0 0 always zero
3 558 → 601 → 621 174 monotonic counter
4 177 → 180 → 120 6 mirrors field 1 exactly
5 15968 → 17184 → 17748 4976 monotonic counter, constant 28.6× ratio to field 3

Fields 3 and 5 rise monotonically at a fixed ratio, so they are cumulative counters (distance and revolutions, in unidentified units), not gears.

Conclusion: this is a read channel. Nothing here is a virtual-shifting command surface, and no gear field appears. Establishing whether the trainer would accept a gear command means fuzzing unknown writes to a device that controls resistance under a rider — open-ended and not obviously safe. Virtual shifting is therefore emulated app-side (§5.4), which the physics model was always designed for.

Corroboration: qdomyos-zwift #3678 reports Click shifting on a D100 dying after 3060 s with the pod's LED dropping out — the §2.3 unlock-timeout signature. Labelled wontfix. Our unencrypted sessions ran past three minutes with button edges still arriving, so this path does not appear to hit that timeout — but a long ride is the real test.

Encryption — not required. A-3 holds for the v2. The pod completed the handshake and streamed button and battery events with no key exchange and no encryption at all. The ECDH P-256 → HKDF → AES-256-CCM path documented below was therefore not needed, and no crypto crate has been added.

Stage Mechanism
Key agreement ECDH on NIST P-256 (prime256v1); each side sends a public key
Key derivation HKDF → 36 bytes: bytes 031 = AES key, bytes 3235 = IV
Cipher AES-256-CCM, 4-byte MAC
Framing 4-byte big-endian counter prepended per message; nonce = IV ‖ counter

Retained only in case a firmware update closes the unencrypted path. It maps onto pure-Rust RustCrypto crates (p256, hkdf, sha2, aes, ccm), so reinstating it needs no C dependency.

Open: whether the daily Zwift-app unlock (§2.3) is needed at all on this path is untested — the successful session followed recent Zwift use, so it cannot yet be distinguished from an unlock that was simply still valid. Re-test after 24 hours away from the Zwift app.

2.4 Environmental assumptions

  • A-3: BLE peripherals accept a single central connection. Only one host may hold the trainer, and only one may hold the Click.
  • A-4: The trainer wakes on pedalling; the Click wakes on button press. Either may be absent from a scan until woken — the UI must say so rather than report "not found".

3. Prior Art, Reuse and the Input Decision

3.1 OpenBikeControl/bikecontrol — Zwift device reference ⚠

Flutter/Dart, 589★, actively maintained. A bridge app that translates controller input into actions for other training apps. Not used as a component here, but its public source documents Zwift device discrimination (manufacturer-data type bytes, v1 vs v2 response codes) and the shape of the v2 unlock.

⚠ Licensing. Current versions are a custom Non-Commercial licence (© OpenBikeControl UG): personal/educational use only, source-only redistribution under the same terms, no commercial use, no marketplace distribution, and the grant is revocable. Versions before the gpl3 tag were GPL-3.0 (copyleft). No code is copied from it — treat it as a map, and confirm bytes against the device.

3.2 obostjancic/smart-trainer-control — D100 FTMS reference

React/TypeScript, Web Bluetooth, MIT. Literally "Control a Van Rysel D100 smart trainer from your browser". Working FTMS client, control-point encoders, and FIT and TCX writers with tests.

MIT means this logic can be ported freely, with attribution. It pre-solves most of TASK-1 and de-risks the FIT writer.

3.3 OpenBikeControl/openbikecontrol-protocol — considered, not used

An MIT open standard (service d273f680-…) for controllers to drive trainer apps, with Python references for both sides. v0.3 proposed consuming it via a bridge process. That approach is dropped — it required a second app running during every ride. Recorded here only so the decision is not silently revisited.

3.4 cagnulein/qdomyos-zwift (QZ) — GPL-3.0 Zwift protocol implementation

C++/Qt, 826★, GPL-3.0. src/zwift_play/ contains a complete working implementation: zapCrypto.h (ECDH/HKDF/AES-CCM), localKeyProvider.h (P-256 keygen), zapBleUuids.h, zapConstants.h, abstractZapDevice.h (handshake), and zwiftclickremote.cpp.

This is the reference that makes direct Click v2 support tractable. It is the source of the crypto details in §2.3.1.

Licensing: GPL-3.0 is copyleft. Porting this code makes BikeControl GPL-3.0. The underlying algorithm — ECDH P-256 + HKDF + AES-256-CCM — is standard cryptography and not itself copyrightable, so a clean implementation from the documented protocol (§2.3.1) is unencumbered. See OQ-9.

3.5 ajchellew/zwiftplay — the original reverse-engineering ⚠

Kotlin/Android + a C# Windows console app. The origin of most public knowledge here, including the packet captures, the RideOn handshake, and the fourth (…0006-…) characteristic.

⚠ No LICENSE file — all rights reserved. Excellent documentation, but no code may be copied from it. Its README is the citable protocol description.

3.6 jat255/zwift_click_handling — MIT, Python, Linux/BlueZ

A small MIT script using bleak on Linux that connects to a Click and logs button press/release and battery level. Notably it connects without encryption and still receives events (A-3), and it carries the protocol constants in reusable form.

MIT and already proven on Linux/BlueZ — the natural basis for the probe tool.

3.7 Decision: direct Click v2, no fallback

v0.3 routed all input through a bridge process because the unlock looked insurmountable. The research above shows it is not: the rider performs the unlock in the free Zwift app, and the protocol afterwards is fully documented across three open implementations.

Zwift Click v2 ──BLE, ECDH P-256 + HKDF + AES-256-CCM──► This app
      (rider unlocks daily in the free Zwift app)
No second app during rides The bridge's real cost was a process running every session. A once-daily 30-second unlock is a far smaller tax.
The protocol is documented §2.3.1 is a complete spec, corroborated by three independent implementations.
Standard crypto, pure Rust p256, hkdf, sha2, aes, ccm — no C dependency, no bespoke cryptography.
Lower latency No intermediate hop in the NFR-1 budget.
One code path No input abstraction to maintain, no divergence between paths.

Accepted consequence. There is no second way in. If a Zwift firmware update breaks the client, or the left pod proves unreliable (RISK-9), controller input is lost until the client is fixed — the keyboard and on-screen controls (FR-3.19) are the only stopgap. This is a deliberate trade of resilience for simplicity.

OQ-9 — licensing. Porting QZ's crypto directly makes this project GPL-3.0. Writing it from the documented algorithm keeps the licence open. Which do you want? For a personal project GPL-3.0 costs nothing.


4. Technology Stack

Tauri v2, Rust core + web frontend. Confirmed; we port rather than import.

Layer Choice Notes
Shell Tauri v2 Desktop + Android from one codebase
Core Rust BLE, physics, profiles, gearing, FIT
BLE btleplug via tauri-plugin-blec blec supplies Android JNI/permission plumbing
Crypto p256, hkdf, sha2, aes, ccm Click v2 session (§2.3.1); pure Rust, no OpenSSL
Frontend Web — Svelte recommended Connection UI, telemetry
Charts uPlot Canvas, built for streaming series
FIT Ported from §3.2 (MIT) See RISK-3

Architectural constraint. The control loop lives in Rust, not JavaScript. tauri-plugin-blec is designed to expose BLE to the frontend; we use its Rust-side API and keep all device I/O, physics and state in the core. The frontend renders telemetry pushed over Tauri events and issues intents as commands. This keeps the safety-critical path (§7) independent of the webview and the core testable without a UI.

bikecontrol/
├── crates/
│   ├── core/        # physics, profiles, gearing, state machine — no BLE, no UI
│   ├── ble/         # FTMS client, Zwift Click v2 client (handshake + crypto)
│   ├── fit/         # FIT encoder (ported from §3.2)
│   └── probe/       # CLI for protocol discovery (Phase 0)
├── src-tauri/       # Tauri shell, commands, event bridge
└── ui/              # web frontend

5. Functional Requirements

5.1 Device discovery and connection (FR-1)

ID Requirement Priority
FR-1.1 Scan for BLE peripherals; list name, address, RSSI, advertised services Must
FR-1.2 Identify trainers by FTMS service UUID and Click pods by the Zwift custom service UUID plus manufacturer-data type byte Must
FR-1.3 Connect to trainer and controller independently; either may connect first Must
FR-1.4 Track the left and right pods as separate connections, since each is an independent peripheral Must
FR-1.5 Remember paired devices and auto-connect on launch Should
FR-1.6 Auto-reconnect on unexpected disconnect, with backoff, without ending the ride Must
FR-1.7 Surface per-device connection state (scanning / connecting / connected / lost) Must
FR-1.8 When nothing is found, prompt to wake the device (per A-4) — pedal the trainer, press a Click button Must
FR-1.9 Allow riding with the trainer alone; on-screen and keyboard controls substitute Must
FR-1.10 A connect or reconnect attempt in flight is cancellable: disconnect and app exit abandon it rather than queue behind it, and any half-open GATT link is closed on the way out Must
FR-1.11 Auto-reconnect is bounded, for the controller as well as the trainer. Giving up is announced as a terminal state carrying the reason — never a silent return to Idle, and never an actor that stops without saying so Must
FR-1.12 Scanning resumes on its own whenever no trainer link is held or being attempted, so a disconnect or a failed connect does not leave a frozen device list Should

Cancellation note (FR-1.10): a connect is a long operation — up to scan_timeout before the peripheral is even found, then connect, service discovery and the control handshake. Waiting for it to finish before honouring a disconnect or a quit is what makes SAF-2 miss its budget, so the attempt must be abandoned instead. Abandoning is not free: the D100 accepts one BLE host (A-3), so an attempt that had already opened a link must close it, or the trainer stays unreachable until it times out on its own.

5.2 Trainer control (FR-2)

ID Requirement Priority
FR-2.1 Acquire FTMS control (0x00) before issuing commands Must
FR-2.2 Subscribe to Indoor Bike Data (0x2AD2); decode all present fields per the flags bitfield Must
FR-2.3 Set gradient via SetTargetInclination (0x03), or 0x11 if TASK-1 confirms support Must
FR-2.4 Set resistance via SetTargetResistanceLevel (0x04) Must
FR-2.5 Set target power via SetTargetPower (0x05) — needed for ERG waveforms Must
FR-2.6 Read and respect Fitness Machine Feature (0x2ACC) and Supported Resistance Level Range (0x2AD6); never send out-of-range values Must
FR-2.7 Handle control-point indications including error responses, not fire-and-forget Must
FR-2.8 Rate-limit control writes (≤4 Hz target) Must

Decoding note (FR-2.2): Indoor Bike Data is variable-length, determined by a leading 16-bit flags field. Bit 0 is inverted — instantaneous speed is present when the bit is clear. Fields must be consumed strictly in specification order.

5.3 Controller input (FR-3)

Input comes from the Zwift Click v2 over BLE, directly (§2.3.1). There is no bridge and no secondary protocol path.

Direct Click v2 client

ID Requirement Priority
FR-3.1 Discover Click pods by Zwift custom service UUID plus manufacturer data; identify left vs right pod Must
FR-3.2 Perform the RideOn handshake and establish the encrypted session — ECDH P-256 → HKDF → AES-256-CCM (§2.3.1) Must
FR-3.3 Attempt the unencrypted path first (A-3) and fall back to encrypted — it is far simpler if the v2 permits it Should
FR-3.4 Connect both pods concurrently; each is an independent BLE peripheral Must
FR-3.5 Decode controller notifications into press/release events for every button on both pods Must
FR-3.6 Handle keepalive/empty messages (0x15) and maintain the session Must
FR-3.7 Decode battery level (0x19) per pod and warn when low Should

Unlock handling — the app guides, the rider performs

The unlock happens in the Zwift app (§2.3). This application never performs it, never asks for Zwift credentials, and never talks to Zwift's servers. It detects the state and walks the rider through the steps.

ID Requirement Priority
FR-3.8 Detect the locked failure mode — notifications stop roughly a minute after connecting — and distinguish it from an ordinary dropout Must
FR-3.9 Track the last known-good unlock per pod and show remaining validity against the ~24 h window Must
FR-3.10 Guide the rider through the unlock with explicit ordered steps (open Zwift → log in → pairing screen → pair the Click → hold a button 1030 s → close Zwift → return here) Must
FR-3.11 State plainly that no paid Zwift subscription is required Should
FR-3.12 Warn before a ride starts when the unlock is stale, expiring soon, or unknown Must
FR-3.13 Detect and confirm success automatically once events resume, closing the guidance without the rider having to declare it worked Should
FR-3.14 Offer "mark as unlocked" for riders who unlocked outside the app Could
FR-3.15 Never store Zwift credentials or contact Zwift services Must

Input handling

ID Requirement Priority
FR-3.16 Debounce input; support press, release and hold-to-repeat Must
FR-3.17 All bindings user-configurable; the mapping below is the default Should
FR-3.18 Hold on D-pad up/down repeats the gradient step at a fixed rate Should
FR-3.19 Keyboard and on-screen controls mirror every action — needed to develop and test the app before the Click client works, and to keep a ride going if a pod dies mid-session Must

Button mapping. Per OQ-1: shift paddles shift, D-pad handles gradient.

Click v2 control Pod Action
Shift paddle + Right Virtual gear up
Shift paddle Left Virtual gear down
D-pad up Left Gradient +0.5%
D-pad down Left Gradient 0.5%
D-pad left Left Previous route / profile
D-pad right Left Next route / profile
Face button A Right Cycle control mode (§5.4)
Face button Z Right Reset gradient offset to zero
Face button B Right Pause / resume ride
Face button Y Right Insert lap marker

RISK-9 applies here. Every gradient and route control sits on the left pod, which is where third-party support is weakest (§10). With no bridge fallback, the left pod is a single point of failure for gradient control — TASK-0 must prove it works before this mapping is committed to.

5.4 Control modes (FR-4)

ID Mode Behaviour Priority
FR-4.1 Virtual gearing Paddles shift a configurable virtual cassette; resistance follows gear × terrain Must
FR-4.2 Manual grade D-pad adjusts simulated gradient in ±0.5% steps Must
FR-4.3 Resistance Buttons step trainer resistance directly, ignoring physics Must
FR-4.4 Route Gradient driven by route profile at current distance; paddles shift Must
FR-4.5 Waveform Gradient/resistance/power driven by a synthetic profile (§5.6) Must
FR-4.6 ERG Trainer holds a fixed target power Should

Gearing and gradient are simultaneously active, not alternatives — the paddles always shift while the D-pad always trims gradient. "Mode" selects where the base gradient comes from (manual, route, or waveform).

Virtual shifting design (FR-4.1). No mechanical shifting exists, and FTMS has no virtual-shifting opcode. Approach:

  1. Define a virtual cassette: N gears (default ~24), ratios configurable.
  2. The app owns the physics (FR-7.1), so it knows the wheel force required for the current virtual speed and gradient.
  3. The selected gear sets the cadence needed to hold that speed: cadence = v / (gear_ratio × wheel_circumference).
  4. The app sets trainer resistance so effort at that cadence matches the physics — effectively resistance = f(gradient, speed, gear_ratio).
  5. Mapping that target onto a D100 resistance level needs empirical calibration of the trainer's resistance curve (TASK-3).
ID Requirement Priority
FR-4.1.1 Configurable gear count and ratios, with sensible defaults Must
FR-4.1.2 Display current gear prominently, with clear feedback on each shift Must
FR-4.1.3 Clamp at top and bottom gear; never wrap around Must
FR-4.1.4 Shift response ≤250 ms end-to-end (NFR-1) Must
FR-4.1.5 Persist gear selection across a reconnect Should

5.5 Routes (FR-5)

ID Requirement Priority
FR-5.1 Import GPX; derive a distance/elevation profile Must
FR-5.2 Smooth GPX elevation before differentiating into gradients — raw GPS elevation is far too noisy to send to a trainer Must
FR-5.3 Clamp derived gradients to a configurable range (default 10%…+15%) Must
FR-5.4 Hand-authored routes as an ordered segment list in a human-editable file Must
FR-5.5 Interpolate between profile points so grade changes are continuous, not stepped Must
FR-5.6 Support looping, and support finishing at the end Should
FR-5.7 Show distance covered, remaining, and elevation profile with current position Must
FR-5.8 Cycle routes from the D-pad mid-ride (0x12/0x13) Must

5.6 Synthetic waveform profiles (FR-6)

ID Requirement Priority
FR-6.1 Waveform types: sine, square, triangle, sawtooth, ramp, constant Must
FR-6.2 Apply to any of three channels: gradient (%), resistance level, target power (W) Must
FR-6.3 Parameterise by amplitude, midpoint, period, phase, and duration or repeat count Must
FR-6.4 Support time-based and distance-based periods Should
FR-6.5 Compose into a sequence of blocks — warm-up ramp, sine intervals, cool-down Must
FR-6.6 Share a file format with hand-authored routes, so a profile may mix terrain and waveform blocks Should
FR-6.7 Render a preview chart before the ride; show position within it during Must
FR-6.8 Clamp generated values to the safe range at transmission (SAF-3) Must
FR-6.9 Smooth transitions between blocks so the trainer does not step discontinuously Should

Illustrative format (TBD):

name: "Over-unders + hill repeats"
blocks:
  - { type: ramp,     channel: power,    from_w: 100, to_w: 200, duration_s: 600 }
  - { type: sine,     channel: power,    midpoint_w: 240, amplitude_w: 40,
      period_s: 120, repeats: 8 }
  - { type: segments, channel: gradient, loop: true, segments: [
        { distance_m: 800, gradient_pct: 6.5 },
        { distance_m: 400, gradient_pct: -3.0 } ] }
  - { type: constant, channel: power,    watts: 120, duration_s: 300 }

5.7 Ride engine (FR-7)

ID Requirement Priority
FR-7.1 Compute virtual speed from measured power, gradient and mass — the app owns the physics rather than trusting the trainer's reported speed Must
FR-7.2 Integrate speed into distance, which drives route position Must
FR-7.3 Model inertia so speed changes feel natural rather than snapping to steady state Must
FR-7.4 Expose rider mass, bike mass, Crr, CdA and wheel circumference as configurable Must
FR-7.5 Use the trainer's reported speed as diagnostic/fallback only Should
FR-7.6 Track elapsed time, moving time, elevation gained, average and normalised power Should
F_propulsive = (P_measured × drivetrain_efficiency) / max(v, v_min)
F_gravity    = m × g × sin(atan(gradient))
F_rolling    = m × g × Crr × cos(atan(gradient))
F_aero       = ½ × ρ × CdA × v²
a            = (F_propulsive  F_gravity  F_rolling  F_aero) / m
v           += a × Δt      (clamped at ≥ 0)

Owning the physics is a prerequisite for virtual shifting, makes behaviour reproducible in tests, and removes dependence on the trainer's internal mass assumptions.

5.8 Recording and export (FR-8)

ID Requirement Priority
FR-8.1 Record a 1 Hz series: timestamp, power, cadence, speed, distance, gradient, gear, mode Must
FR-8.2 Export a valid FIT activity (file_id, session, lap, record, activity; correct CRC) Must
FR-8.3 Verify exported FIT files import into Strava and Garmin Connect Must
FR-8.4 Persist the raw series so a FIT can be regenerated after a crash Must
FR-8.5 Continue recording across a BLE dropout, marking the gap rather than aborting Must
FR-8.6 Write incrementally — a crash must not lose the session Must
FR-8.7 Record lap markers triggered from the controller (0x35) Should

Only FIT export was requested. In-app ride history is not in scope for v1 — the FIT file is the ride artifact. FR-8.4's raw log is crash safety, not a history feature.

5.9 GUI (FR-9)

Connection screen

ID Requirement Priority
FR-9.1 Live device list with signal strength and identified type Must
FR-9.2 Per-device connect / disconnect / forget, with clear state and error text Must
FR-9.3 Show FTMS control acquisition separately from BLE connection — connected ≠ controllable Must
FR-9.4 Show per-pod connection and unlock state, with a guided walkthrough of the Zwift unlock (FR-3.10) launched from here and from any locked-state warning Must

Ride screen

ID Requirement Priority
FR-9.5 Large, legible readouts readable at ~1 m. The screen is route-led, not power-led — ETA, distance remaining, speed, gradient and elevation take visual priority; power, cadence and heart rate are present but subordinate Must
FR-9.6 Live streaming charts of power and gradient/target Must
FR-9.7 The route elevation profile with current position marked is the primary visual element of the ride screen, not a supporting chart. For waveform profiles, the profile preview serves the same role Must
FR-9.15 Estimated time to finish (ETA), alongside distance covered and remaining Must
FR-9.8 Prominent display of active mode, current gear, and current target Must
FR-9.9 Visible feedback on every button press, so the rider knows input registered Must
FR-9.10 On-screen and keyboard equivalents for all controller actions Must
FR-9.11 Instantaneous power is noisy — show a rolling average alongside or instead Should
FR-9.12 Dark theme suitable for indoor training Should
FR-9.16 Estimated energy expenditure in kcal, from measured work and rider mass. One model shared by the live readout and the exported FIT, so the two agree; the trainer's own energy field is not used, because FTMS does not define what it means Should

Post-ride

ID Requirement Priority
FR-9.13 Summary: duration, distance, elevation, average/max power, average cadence Must
FR-9.14 Save FIT to a chosen location, with the path confirmed Must

6. Non-Functional Requirements

ID Requirement
NFR-1 Input latency — button press to trainer resistance change ≤ 250 ms
NFR-2 Telemetry — process trainer notifications at native rate (14 Hz) without backlog
NFR-3 UI — charts stay smooth for a 2-hour ride without unbounded memory growth
NFR-4 Resilience — no BLE dropout, malformed packet or missing characteristic may crash the app
NFR-5 Portabilitycore and fit compile for Android with no platform-specific code
NFR-6 Offline — full functionality with no internet connection. The app never contacts Zwift or any other service; only the rider's separate daily unlock needs the internet
NFR-7 Startup — launch to scanning in under 3 seconds
NFR-8 Observability — all BLE traffic loggable at debug level, including decrypted Click frames, for protocol diagnosis
NFR-9 Shutdown — every exit path completes the SAF-2 sequence and closes within 8 seconds, whatever the radio was doing when the rider quit
NFR-10 No busy-waiting — a supervisor whose event source has closed drops it. No loop may spin on a permanently-ready future, and no arm of a biased select may starve the one that carries the shutdown command
NFR-11 Screen stays lit — a live ride (running or paused) holds a system idle inhibitor, so the display never blanks or locks under a rider whose hands are on the bars. Held for the ride, not for the app: it is taken when the ride starts and released when it ends, however it ends. Failing to take it costs a blanked screen, never a ride

7. Safety Requirements

ID Requirement
SAF-1 On controller disconnect, hold the last target — never continue applying pending increments
SAF-2 On app exit, crash, or ride end, reset the trainer to 0% grade / minimum resistance
SAF-3 Clamp gradient, resistance and target power to configurable safe ranges at the point of transmission, regardless of source
SAF-4 If the trainer stops acknowledging control-point writes, stop sending and alert the rider
SAF-5 Never issue a step larger than one configured increment per input event
SAF-6 Waveform parameter errors must not command an unsafe target (enforced by SAF-3)
SAF-7 A stale or replayed Click frame must not re-trigger an action — enforce the session counter and reject out-of-order frames
SAF-8 The shutdown sequence preempts in-flight BLE work. A connect or reconnect is abandoned, and every step of the reset is individually bounded, so SAF-2 finishes inside the NFR-9 budget rather than timing out
SAF-9 Every BLE link the app owns — trainer and controller — is explicitly closed on exit. A link the process merely abandons can leave the peripheral held and unreachable on the next launch (A-3)

8. Architecture

┌──────────────────────────────────────────────────────┐
│  Frontend (webview)                                  │
│  Connection manager · Gauges · uPlot · Profile editor│
└──────────────▲────────────────────────┬──────────────┘
        events │                        │ commands
┌──────────────┴────────────────────────▼──────────────┐
│  Rust core                                           │
│  ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐  │
│  │ Ride     │ │ Profile  │ │ Virtual │ │ Recorder │  │
│  │ engine   │◄┤ engine   │ │ gearing │ │ → FIT    │  │
│  │(physics) │ │(GPX/wave)│ └────┬────┘ └──────────┘  │
│  └────▲─────┘ └──────────┘      │ target             │
│  ┌────┴───────────┐  ┌──────────▼──────┐             │
│  │ FTMS client    │  │ Click v2 client │             │
│  │ (trainer, BLE) │  │ (BLE + crypto)  │             │
│  └────────▲───────┘  └────────▲────────┘             │
│           │  btleplug / blec  │                      │
└───────────┼───────────────────┼──────────────────────┘
            │                   │  ECDH P-256 → HKDF
            │                   │  → AES-256-CCM
     ┌──────┴──────┐     ┌──────┴──────────────┐
     │  D100       │     │ Click v2 L + R pods │
     └─────────────┘     └─────────────────────┘
                                 ▲
                         daily unlock performed
                         by the rider in Zwift

9. Delivery Plan

Phase 0 — D100 protocol discovery (blocking for Phase 1)

Task Description
TASK-1 probe against the D100: enumerate services, dump Fitness Machine Feature and Supported Resistance Level Range, log decoded Indoor Bike Data, resolve whether 0x11 works (A-1)
TASK-2 Write control commands to the D100; confirm physical resistance change

Exit criteria: telemetry decodes correctly and a written command produces a felt resistance change.

Phase 0b — Deferred discovery (blocking for Phase 3 only)

Task Description
TASK-0 Prove the Click v2 — the riskiest thing in the project. Unlock both pods in the Zwift app, then with probe: (a) try the unencrypted path (A-3); (b) if that fails, do the full RideOn + ECDH/HKDF/AES-CCM handshake; (c) log raw and decrypted frames against a known button sequence; (d) confirm every button on both pods registers (RISK-9); (e) measure how long events survive without a fresh unlock. Prototyping in Python against §3.6's MIT code is legitimate — the goal is knowledge, not shipped code
TASK-3 Characterise the D100 resistance curve — required to map virtual gears onto it (FR-4.1)
TASK-4 Android BLE spike: minimal Tauri v2 Android build scanning via blec (RISK-1)

If TASK-0(d) fails on the left pod, resolve OQ-10 before building the button mapping.

Phase 1 — D100 + GUI + profiles (current focus)

The Click is deferred to Phase 3. All control in this phase is on-screen; the app is fully rideable from the keyboard and mouse before any Zwift protocol work begins. This removes the only component with unknown protocol risk from the critical path.

FR-1 (trainer only), FR-2, FR-4.2/4.3/4.4/4.5, FR-5 (GPX), FR-6 (waveforms), FR-7 (physics), FR-3.19 (keyboard/on-screen), FR-9.19.3, 9.59.8, 9.109.12, §7 safety.

Milestone: load a GPX or a sine-wave profile, ride it, and control gradient from the GUI.

Phase 2 — Recording

FR-8 (recording, FIT, crash safety), FR-9.13/9.14. Milestone: a ride lands in Strava.

Phase 3 — Zwift Click v2

TASK-0 (protocol discovery), then FR-3.13.18, FR-9.4, FR-4.1 (virtual gearing, which needs TASK-3's resistance-curve calibration), RISK-9 resolution. Milestone: shift gears and trim gradient from the bars.

Phase 4 — Polish and stretch

FR-1.5 (auto-connect), FR-3.7 (battery), FR-3.14 (mark as unlocked), FR-3.17 (rebinding), FR-4.6 (ERG), FR-9.11/9.12, Android.


10. Risks

ID Risk Impact Mitigation
RISK-1 btleplug's Android backend is the least mature part of the stack Android target lost TASK-4 spikes it in Phase 0
RISK-2 Virtual shifting feel may be poor if the D100's resistance curve is coarse or laggy Core feature degraded TASK-3 characterises it early; fall back to fewer, wider-spaced gears
RISK-3 Rust FIT encoders are thin — most crates read rather than write FR-8.2 slips Port the MIT writer from §3.2; TCX fallback
RISK-4 No fallback input path (§3.7). A firmware change or a protocol error leaves no second way in Controller input lost entirely until the client is fixed Accepted deliberately. Keep protocol handling isolated in ble; pin known-good behaviour in tests; keyboard/on-screen controls (FR-3.19) are the only stopgap
RISK-5 The Click v2 crypto is more involved than the D100 work — ECDH, HKDF, AES-CCM, session counters — and a subtle error yields silence rather than a clear failure Phase 1 slips Three reference implementations to check against (§3.43.6); probe logs raw and decrypted frames side by side (NFR-8)
RISK-6 The unencrypted path (A-3) may not work on a v2, forcing full crypto immediately Less schedule slack TASK-0 tests it first; the crypto path is specified either way
RISK-7 The unlock expires ~24 h, so a forgotten re-unlock blocks a ride Frustration at session start Detect and warn before the ride (FR-3.12), with a guided walkthrough (FR-3.10)
RISK-8 D100 lacks 0x11 sim mode Reduced fidelity Low impact — the app owns the physics
RISK-9 The left pod is where third-party support is weakest. QZ has an open wontfix issue where the left Click's never registers, and BikeControl's keep-alive covers only the right pod. All gradient and route control is mapped to the left pod Gradient and route control lost; shifting-up survives TASK-0 proves both pods before the mapping is committed. If the left pod is unreliable, remap onto the right pod and move gradient to a modifier gesture

10.1 On ANT+

The D100 likely supports ANT+ FE-C, which permits multiple simultaneous connections and would sidestep BLE contention. Excluded because it needs a USB stick on desktop and is effectively dead on Android — the opposite of G-4. Revisit only if BLE contention proves intolerable.


11. Open Questions

ID Question
OQ-2 Gradient step size — is ±0.5% per D-pad press right? And what repeat rate on hold?
OQ-3 How many virtual gears, and what ratio spread? (Default: 24, roughly a 2×12 road setup.)
OQ-4 Should route mode auto-advance to the next route on completion, or stop and wait?
OQ-5 Is heart rate wanted? A BLE HRM strap is a small increment now and awkward to retrofit into the FIT writer later.
OQ-6 Which frontend framework? Svelte recommended.
OQ-9 (§3.7) Port QZ's GPL-3.0 crypto and licence this project GPL-3.0, or reimplement from the documented algorithm and stay unencumbered?
OQ-10 If TASK-0 shows the left pod is unreliable (RISK-9), do you want gradient remapped onto the right pod, or would you rather chase the left-pod bug?

Resolved: OQ-1 (paddles shift, D-pad adjusts gradient) · OQ-7 (stay on Tauri, port the MIT logic) · OQ-8 (no bridge, no fallback input path) · Click version (v2).


12. Glossary

Term Meaning
FTMS Fitness Machine Service — standard BLE profile for trainer telemetry and control
Pod One half of a Click v2 — left (D-pad) or right (face buttons); each is an independent BLE peripheral
Unlock Refreshing the Click v2's encryption context via the free Zwift app; lasts ~24 h
ECDH / HKDF / AES-CCM The key agreement, key derivation and cipher used by the Zwift session (§2.3.1)
ERG mode Trainer holds fixed target power regardless of cadence
Sim mode Trainer applies resistance simulating a gradient; power varies with rider effort
Virtual shifting Synthesising gear changes by varying trainer resistance, on a single-cog drivetrain
Crr Coefficient of rolling resistance
CdA Drag coefficient × frontal area
Normalised power Weighted average power reflecting physiological cost of variable efforts