Files
BikeControl/REQUIREMENTS.md
T
dtourolleandClaude Opus 5 7c17ca6158 Core ride logic, FTMS client, FIT encoder and probe CLI
Adds backing state for Resistance and Erg control modes, which had no
value to hold and so could never satisfy FR-4.3/FR-4.6.

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

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

Established from three independent open-source implementations (§3.43.6).

Item Value
Custom service 00000001-19CA-4651-86E5-FA29DCDD09D1
Async (notify) 00000002-… — button notifications
Sync RX (write) 00000003-… — commands to device
Sync TX (indicate) 00000004-… — responses
Unknown (indicate/read/write) 00000006-… — purpose undetermined
Manufacturer ID 2378 (0x094A); device byte 0x09 = Click v1, 0x0A/0x0B = v2
Handshake Write RideOn (52 69 64 65 4F 6E) + 2 bytes to Sync RX; device replies on Sync TX
Message types 0x07 controller notification · 0x15 empty/keepalive · 0x19 battery · 0x37 Click button state (two protobuf varints)

Encryption. The handshake performs a key exchange and messages are then encrypted:

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

All of this maps onto pure-Rust RustCrypto crates (p256, hkdf, sha2, aes, ccm), so no C dependency is needed.

A-3: At least one implementation connects to a Click without encryption and still receives button and battery events (§3.6). Whether that holds for a v2 is unknown — TASK-0 tests the unencrypted path first, since it would be far simpler.

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

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

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

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

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