Files
BikeControl/REQUIREMENTS.md
T
dtourolleandClaude Opus 5 87e97e104f Write down what the pods actually did
Four findings from an evening on the hardware, three of which contradict
what this document said.

The undocumented `0000010x` characteristics are TI OAD firmware update:
their `0x2901` descriptors read "Img Identify", "Img Block" and "OAD
Extended Control". The table called them "undocumented, silent so far",
which invites probing them to see what answers — and the answer is that
`Img Block` takes firmware. They are silent because OAD says nothing
until an update is running. Marked do-not-write.

A pod offering a public key does not mean it wants encryption. The `−`
pod sent a compressed P-256 point in the same session as 304 cleartext
button frames. That was read twice as "it has switched to the encrypted
path", and neither reading survived the capture it came from. The crypto
section now says so, because the mistake is an easy one and it cost an
evening.

TASK-0(d) is satisfied and RISK-9 is retired. Every button on both pods
registers on the bit §2.3.1 documents: D-pad 0-3 and `−` on bit 8 across
four cycles, `A`/`B`/`Y`/`Z` on 4-7 and `+` on bit 12 over 83 presses.
QZ's `wontfix` for the left `−` does not reproduce here, so OQ-10 needs
no answer and gradient control stays on the left pod.

The evening the left pod appeared dead was a SAF-9 violation, now §7.1.
A half-closed link streams battery and no buttons, which is
indistinguishable from broken hardware — and it was diagnosed as a dead
pod, a wrong bit map, mis-filed pods and a lapsed unlock before anyone
looked at what the previous run had failed to close.

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

54 KiB
Raw Permalink 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) TI OAD firmware update — do not write. Their 0x2901 descriptors name them: Img Identify, Img Block, OAD Extended Control Confirmed, read 2026-08-20
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 in cleartext, and the app has never sent a key. The ECDH P-256 → HKDF → AES-256-CCM path documented below is therefore not needed, and no crypto crate has been added.

A pod offering a public key does not mean it wants encryption. Captured on 2026-08-20: the pod sent ff 03 00 0a 21 02 … — protobuf field 1, 33 bytes, a compressed P-256 point — in the same session as 304 cleartext 0x23 button frames. The key offer and the cleartext stream coexist. This cost an evening: a pod that had gone quiet for an unrelated reason was read as "it has switched to the encrypted path", twice, and neither reading survived contact with the capture. If a pod goes silent, the key frames are not the evidence — see the teardown leak in §7.

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 directly One platform::Manager on every target. On Android its "droidplug" backend is half Java: the classes are taken from the crate source at the Cargo.lock version (NFR-14), platform::init is called from MainActivity.onCreate, and the runtime permissions are requested there. tauri-plugin-blec was the v0.4 plan and would have supplied that plumbing, but it wraps btleplug in its own device model, which crates/ble would then have to be written against twice
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
FR-9.17 Screen size is a measured input to the layout, not an assumption. Viewport width, height, device pixel ratio and pointer coarseness are measured at startup and on every resize, rotation and fold, and a single pure function maps them to type sizes in pixels, column counts and which sections are shown. There is exactly one definition of "narrow" in the codebase; stylesheets consume the result and never restate a breakpoint of their own Must
FR-9.18 Legibility floors are absolute. No readout scales below the size at which it stops being readable from the riding position — the primary number never below 40 px, secondary never below 28 px. A viewport too small for the content sheds content instead: sparklines first, then the summary-style effort numbers, then the route detail row. The route profile (FR-9.7) and the live numbers are the last to go Must
FR-9.19 Touch is a first-class input on Android. Every control is at least 48 px on a coarse pointer, hover styling is suppressed, and keyboard hints are hidden — with a word on every control that carried only a key cap Must

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. On Android this is FLAG_KEEP_SCREEN_ON, set for as long as the app is in the foreground: it needs no permission and lapses on backgrounding, so there is nothing to leak — but it is coarser than the desktop inhibitor, which is scoped to the ride
NFR-12 Reproducible builds — every artifact (Linux .deb/AppImage, Arch .pkg.tar.zst, Android APK) is built in CI from a pinned container image, not from a developer's machine. The images are built from Dockerfile.builder and Dockerfile.arch in this repo
NFR-13 No untracked sourcesrc-tauri/gen/ is generated and untracked, so every hand-written Android source lives in src-tauri/android/ and is copied in after each tauri android init. CI fails if a source exists only under gen/, or differs from its tracked copy. The failure this prevents is silent: an APK that builds and installs and then behaves as if the file was never written
NFR-14 Version-locked BLE Java — btleplug's Android backend is half Java, and a Java/Rust mismatch surfaces as a NoSuchMethodError at the first scan rather than a build error. Those classes are therefore taken from the crate sources at the versions in Cargo.lock, never from a separately published artifact

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)

7.1 How SAF-9 was violated, and what it looked like (fixed 2026-08-20)

Worth recording, because the symptom pointed everywhere except the cause.

Actor::teardown unsubscribed every notifying characteristic before disconnecting, each under its own DISCONNECT_TIMEOUT of 2 s. A Click carries five of them, so the exit path could spend 10 s on optional work against the controller's 3 s shutdown budget. The supervisor gave up first, the process exited, and disconnect was never reached — leaving BlueZ holding the pod with no application running. Confirmed directly: both apps stopped, bluetoothctl devices Connected still listing it.

The next connect then inherited a half-dead session, and that is the part worth remembering: it streamed battery every ~5 s and no button frames at all. A pod that answers the handshake, reports battery, and ignores every press is indistinguishable from broken hardware, and it was read that way — as a dead left pod (RISK-9), as a wrong bit map, as pods filed into the wrong slot, and twice as a lapsed Zwift unlock. None of those survived a capture. It also produced the connect failures that came with it, le-connection-abort-by-local and service discovery timed out, and it came and went depending on whether the previous exit had happened to time out.

Three rules fall out of it:

  • Nothing optional runs before the disconnect on an exit path. Dropping the link clears the peripheral's CCCDs anyway, so the unsubscribes were politeness toward a connection about to be destroyed. The trainer's SAF-2 writes stay, because those are not optional.
  • A timed-out disconnect is logged with its consequence, not just its name. The cost lands on the next run, which is exactly why this hid for so long.
  • Android is the worse case. A leaked link there survives the app being swiped away.

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 (RISK-1). Built, not yet proven on hardware: the APK builds via btleplug's droidplug backend, and the manifest carries the Android 12+ runtime pair BLUETOOTH_SCAN + BLUETOOTH_CONNECT with android:usesPermissionFlags="neverForLocation" on the scan permission — which we can claim honestly only because every scan is filtered by service UUID — plus ACCESS_FINE_LOCATION capped at API 30 for older releases. MainActivity requests them at runtime before the first scan, not merely declares them. What remains is the actual spike: install on a phone and confirm a scan finds the D100 and both Click pods, and that a connect and a control-point write survive a screen-off

TASK-0(d) satisfied — RISK-9 retired (2026-08-20). Four connect/disconnect cycles against the left () pod with probe zwift --buttons: every button registers, on the bit §2.3.1 documents — D-pad left/up/right/down on bits 03, the paddle on bit 8 (6, 30 and 14 presses across three rounds). The right pod was mapped the same way: A/B/Y/Z on bits 47 and the + paddle on bit 12, over 83 presses. Both halves of the bit table are now hardware-verified, and QS's wontfix for the left does not reproduce here. OQ-10 does not need answering and the gradient mapping can stay on the left pod.

The left pod did appear dead for most of an evening first. It was not: see the teardown leak in §7.

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 Partly retired: the target builds, the Java backend is version-locked to the crate (NFR-14) and the JNI init is wired and symbol-checked in CI. What is not yet proven is a scan and a connect on real hardware — TASK-4
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 RETIRED 2026-08-20 The left pod was where third-party support was weakest — QZ has an open wontfix where the left Click's never registers Did not reproduce. Every button on both pods verified on its documented bit (§5.3). The one evening the left pod did look dead was the teardown leak in §7, not the pod

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