Files
BikeControl/REQUIREMENTS.md
T
dtourolleandClaude Opus 5 41574f694e Record what the interface now promises
Six rows, each of which the last three commits either implement or were
written to stop being ambiguous about.

FR-7.4a says what "configurable" has to mean: reachable from the app,
validated, and durable. FR-7.4 has been a Must since the first draft and
was read as satisfied by a command that existed — which is how every
rider ended up on a 105 kg default.

FR-7.5a puts units on the display side of the line and keeps recordings
SI. FR-9.20 and FR-9.21 are the device screen: roles before radios, and
no MAC addresses. FR-9.22 and FR-9.23 are the ride screen: zones only
against a reference the rider gave, and gradient as a shape with the
next block announced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:15:18 +02:00

79 KiB
Raw Permalink Blame History

BikeControl — Requirements Specification

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

v0.5 changes — the ride leaves the app. §5.10 adds two ways for a finished activity to reach somewhere else: an Android share sheet, which needs no credential and is the only route into Garmin Connect; and an opt-in upload to Runalyze, chosen because its auth is a token the rider pastes in rather than an OAuth flow with a client secret to hide. Strava is deliberately not in scope (RISK-11). This is the first network traffic the app has ever originated, so §1.2's non-goal and NFR-6 are both amended to say precisely what is and is not permitted — the short version being that nothing on the network is ever between a rider and a ride.

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 acting as a network service. The app never listens on a socket and never holds an account of its own. One-way export of a finished ride to a destination the rider configured (§5.10) is not that, and is the only network traffic the app ever originates.
  • 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.

One link is the whole controller — confirmed 2026-08-21. Pairing the pod alone delivers all ten buttons on this hardware: its own paddle and D-pad, plus the + paddle and face buttons relayed from its twin. There is nothing a second link adds, and there is one thing it takes away — connected as a pair, the pod stops reporting its own paddle.

So the app pairs the pod and stops there (controller::take_plus_pod). The + pod is a substitute, not a second half: it is connected on sight only when no pod is known, or when a known one has been unreachable for PLUS_GRACE, so a flat pod costs the rider a D-pad rather than a controller. Buttons stays exactly as it is — it is what makes the handover between the two configurations invisible.

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
HTTP reqwest + rustls-tls Activity upload only (FR-10), and nothing else in the app opens a socket. Not native-tls: OpenSSL cross-compiled against the Android NDK is a build problem this project does not need, and pure-Rust crypto is already the choice for the Click session

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)
│   ├── sync/        # activity upload — one trait, one impl per service (§5.10)
│   └── 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.5a Remembering survives the process: trainer, pod and heart rate monitor are written to devices.json in the app data directory when a link actually comes up, and a device the rider forgets is recorded as refused rather than merely dropped Should
FR-1.5b Auto-connect is driven by the scan, not by startup — the hardware is asleep at launch (A-4), so a remembered device is reconnected the moment it advertises. Bounded by AUTO_ATTEMPTS, so "gave up" (FR-1.11) is not contradicted two ticks later, and suspended for any device the rider disconnected by hand this session 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.4a Configurable means reachable and durable. A setup screen in the app edits them, Rust validates and refuses values that make the engine produce nonsense, and the result is written to settings.json in the app data directory so it survives the process. A command the UI never calls does not satisfy FR-7.4 — it left every rider on the 105 kg default Must
FR-7.5a Units are a display preference (metric or imperial), applied at the last step before the glass. Everything computed, stored and recorded stays SI, so a FIT file never depends on what the screen was set to Should
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
FR-9.20 The setup is presented as roles, not radios. Trainer, shifter and heart rate each get a slot that says what is filling it, what is missing and the one action that would fill it; the device list of FR-9.1 sits below them. A peripheral of no known role is listed but folded away — unless no trainer has been identified at all, since a trainer that does not advertise FTMS until it is connected classifies as unknown Should
FR-9.21 No MAC addresses on screen. An address is not something a rider reads, types or acts on, and on Android it is randomised. The device kind is a glyph and the identity is the name; where two rows would otherwise be indistinguishable, four characters of the address disambiguate them Should

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.22 Effort is shown as a zone where the rider has given a reference. Power against FTP (Coggan's seven) and heart rate against maximum (five), applied to the rolling average rather than the instantaneous figure so the colour does not strobe at 4 Hz. No reference, no zone: an unset FTP draws the plain number rather than a zone measured against a guess. Colour never carries the meaning alone — the zone's short name is rendered beside it Should
FR-9.23 Gradient is a shape as well as a number, and the profile's next block is announced with the distance or time to it — what is coming decides whether to shift now Should
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

5.10 Sharing and upload (FR-10)

Everything in this section operates on a file that already exists. stop_ride writes the activity and then emits the summary (FR-8.2), so by the time a rider can ask for any of this, the ride is on disk and safe. Nothing here touches the ride loop, the BLE stack or the recorder, and nothing here may sit between a rider and a ride: the app stays fully usable with the network down and no account anywhere (NFR-6).

Two destinations, deliberately different in kind.

Share (FR-10.110.8) hands the file to another app on the phone. No network code, no credential, no service-specific knowledge and no terms of service on our side — the rider picks Strava, Garmin Connect, Dropbox or email from the system chooser and that app does the rest. It is the only route into Garmin Connect, whose Connect Developer Program is partner-approval-only and effectively closed to new applicants, and it is the cheapest thing in this document that a rider would use every day.

Upload (FR-10.910.24) sends the file to one named service over HTTPS. That service is Runalyze, chosen for what it does not require: no OAuth, no registered application, no client secret to hide inside an open-source binary, and no per-application rate ceiling shared across every install. Strava has all four of those problems and is out of scope here — RISK-11. Runalyze is the cheap case that proves the trait, the queue and the failure classification; a second service should then cost one new impl and nothing else.

5.10.1 Share the activity to another app (Android)

ID Requirement Priority
FR-10.1 Share the finished activity to any app on the device that accepts a file, from the post-ride summary (FR-9.13) and from the recovered-ride list (FR-8.4) Must
FR-10.2 The share hands out a content:// URI from the app's own FileProvider with FLAG_GRANT_READ_URI_PERMISSION, scoped to the receiving app for the life of the intent. The FIT is never copied out of app-private storage to make this work Must
FR-10.3 The receiving app sees the ride's own filename — 2026-08-21T18-42-10.fit — not a temporary name. A rider looking at Garmin Connect a week later must be able to tell which ride is which Must
FR-10.4 Presented with Intent.createChooser, so the rider always gets a picker and Android never silently commits them to a default Must
FR-10.5 The share reads the automatic copy in the rides directory. Nothing is moved and nothing is deleted, so a dismissed chooser costs nothing — the same contract save_copy already holds for FR-9.14 Must
FR-10.6 Any ride still on disk can be shared, not only the one that just ended Should
FR-10.7 If nothing on the device accepts the file, say so plainly and point at FR-9.14's save dialog as the way out Must
FR-10.8 Desktop is out of scope. FR-9.14's save-to-a-chosen-location is the desktop share; there is no xdg-open handoff and no portal

Mechanism.

The manifest already declares a FileProvider under ${applicationId}.fileprovidertauri-plugin-dialog put it there — but its generated res/xml/file_paths.xml grants only <external-path> and <cache-path>. The rides directory is neither: app_data_dir() on Android is internal, app-private storage. file_paths.xml therefore needs a <files-path> entry covering rides/, and per NFR-13 that file must become a tracked source under src-tauri/android/src/main/res/xml/, copied in by scripts/sync-android-sources.sh — otherwise the next tauri android init regenerates the two-entry version over it. This is precisely the silent failure NFR-13 exists for: an APK that builds, installs, runs, and throws IllegalArgumentException: Failed to find configured root the first time anyone presses Share.

The share itself is one Kotlin method on MainActivity, called over JNI from android.rs exactly as requestBluetoothEnable already is, plus one Tauri command in front of it:

ACTION_SEND
  type          = <OQ-11>
  EXTRA_STREAM  = FileProvider.getUriForFile(ctx, "$packageName.fileprovider", fit)
  addFlags(FLAG_GRANT_READ_URI_PERMISSION)
  → startActivity(Intent.createChooser(intent, …))

startActivity may only be called from the UI thread, and the thread hop belongs in Kotlin for the reason requestBluetoothEnable documents: Rust has no cheap way to get there.

The MIME type is the one part that cannot be settled from a desk. application/vnd.ant.fit is the registered type; application/octet-stream is what a good many Android apps actually filter on. Guessing wrong yields an empty chooser, not an error — OQ-11, RISK-10.

5.10.2 Upload the activity to Runalyze

ID Requirement Priority
FR-10.9 Upload a finished activity to Runalyze Must
FR-10.10 The only credential ever asked for is a personal API token, generated by the rider at runalyze.com/settings/personal-api and pasted in. No password, no OAuth, no account of ours Must
FR-10.11 Upload is opt-in and off by default. An installation that has never been given a token originates no network traffic of any kind, ever (NFR-6) Must
FR-10.12 With auto-upload on, a finished ride is queued at the moment the activity is written — not when the rider happens to reach a screen Must
FR-10.13 With it off, the rider can still upload a chosen ride on demand Must
FR-10.14 The upload never blocks the summary, the UI, or the next ride. A rider who finishes and immediately starts again waits on nothing Must
FR-10.15 The queue survives process death, a force-stop and a flight-mode ride. A ride queued in a basement uploads when the phone next sees a network, on a later launch if that is when it happens Must
FR-10.16 Retries are bounded and backed off; the backoff is persisted, so a restart cannot reset it into a hot loop Must
FR-10.17 Errors are classified retryable (DNS, TLS, timeout, 5xx, 429) or permanent (bad token, rejected file, other 4xx), and the two are never conflated. A bad token must not be retried for a week, and a dropped connection must not be reported as a rejected ride Must
FR-10.18 Idempotent per (ride, service): a ride already accepted is never sent twice, whatever the rider presses Must
FR-10.19 A duplicate reported by the service is recorded as success, not failure. That single rule is what makes FR-10.16's retries safe Must
FR-10.20 recording::prune never deletes a ride with a non-terminal queue entry, however old it is Must
FR-10.21 The rider can see the state of every queued upload, retry a failed one, and cancel one outright Should
FR-10.22 The token is never written to a log, never put in a message shown to the rider, and never emitted as a tracing field at any level — NFR-8's "log everything" stops at the credential Must
FR-10.23 TLS certificate verification is never disabled, and no setting exists that disables it Must
FR-10.24 A failed upload cannot damage the ride. The rides directory is read-only to this whole subsystem Must

The API, as we use it. Confirmed against Runalyze's own reference client (Runalyze/personal-api, bash/upload-activities.sh):

POST https://runalyze.com/api/v1/activities/uploads
token: <personal token>
Content-Type: multipart/form-data
  file=@2026-08-21T18-42-10.fit
Element Value State
Endpoint POST /api/v1/activities/uploads Confirmed
Auth a token: header — not Authorization: Confirmed
Form field file Confirmed
Accepted formats fit, tcx, gpx, ttbin, fitlog Confirmed
Auth failure response body contains No valid token Confirmed — the only error the reference client checks
Success body Unknown, OQ-12
Duplicate response Unknown, OQ-12
Rate / size limits none published Unknown

Two consequences worth stating before anything is written.

Processing is asynchronous on their side. Runalyze's own README says uploads are queued and do not appear immediately. There is no activity id to poll for and no "it is live" moment to report, so an accepted upload means accepted and the UI must say that — not "uploaded to Runalyze", as though the rider could go and look at it. This is simpler than Strava's upload-then-poll; the honest wording is the whole of the difference.

The success and duplicate bodies must be captured, not guessed. The reference client discards the body apart from one substring check, and the published documentation is behind a login. Before FR-10.19 can be implemented, one real upload and one deliberate re-upload of the same file are run by hand and both bodies pinned as fixtures — the same discipline §2.3.1 applied to the Click's handshake reply, which turned out to be 02 03 and not the documented 01 03. Until that is done the honest classification is: a 2xx not containing No valid token is accepted, and duplicate detection does not exist.

5.10.3 The upload queue

The design problem here is not the HTTP. It is that a rider finishes in a basement, on a phone with no signal, and closes the app. stop_ridePOST is wrong for exactly the reason stop_ride → save-dialog was wrong: it makes the ride's fate depend on a moment that may not go well. §5.8 solved that by writing the file first and offering the dialog second. This is the same solution one layer out — an upload is a pending intent recorded next to the file, not an action attempted once.

app_data_dir()/uploads.json
{ "version": 1,
  "entries": [ { "ride":          "2026-08-21T18-42-10",
                 "target":        "runalyze",
                 "state":         "pending" | "done" | "failed",
                 "attempts":      3,
                 "nextAttemptMs": 1755800000000,
                 "detail":        "No valid token" }, … ] }

Same contract as devices.json and settings.json: versioned, an unknown version discarded rather than guessed at, written only when something changes, and advisory — an unreadable uploads.json costs the rider an upload, never a ride, so every failure there is logged and swallowed.

From Event To
(ride written, or the rider asks) queued pending, attempts 0
pending accepted — or duplicate (FR-10.19) done, terminal
pending retryable error, attempts left pending, attempts + 1, nextAttemptMs pushed out
pending retryable error, attempts exhausted failed, terminal, carrying the last reason
pending permanent error failed, terminal, carrying the reason
failed explicit rider retry (FR-10.21) pending, attempts 0
any the FIT is no longer on disk entry dropped
  • Backoff exponential from 30 s, capped near an hour, and persisted — a relaunch must not reset a failing entry into a tight retry loop against someone else's server.
  • Attempts capped (10). Past that the entry is failed carrying the last retryable reason, and only an explicit rider retry revives it.
  • The drain runs at launch — after recover_orphans, so a ride rebuilt from its journal can be queued too — and on a low-frequency timer thereafter. Never on the ride loop's thread, and it never holds the ride-state lock across a socket, for the reason RecorderHandle documents about holding it across an fsync.
  • One upload in flight at a time. Concurrency buys nothing here and costs correctness.
  • An entry whose FIT has gone (deleted by a rider, pruned by an older build) is dropped, not retried.

Prune (FR-10.20). prune currently keeps the newest 20 stems and deletes the rest. It gains a predicate: a stem with a non-terminal entry in uploads.json is never deleted. Without that, a laptop left off for three weeks quietly deletes the rides it was going to upload.

5.10.4 Where the code goes

crates/sync/          # NEW — one trait, one impl. No Tauri, no filesystem policy.
├── lib.rs            #   ActivityTarget, UploadOutcome, UploadError
└── runalyze.rs       #   the multipart POST and its response classification
src-tauri/src/
└── uploads.rs        # NEW — the store, the queue, the drain task, the commands
pub struct Activity<'a> { pub path: &'a Path, pub filename: &'a str }

pub enum UploadOutcome { Accepted { remote_id: Option<String> }, Duplicate }

pub enum UploadError {
    /// Try again later: DNS, TLS, timeout, 5xx, 429.
    Retryable(String),
    /// Do not try again: bad token, rejected file, any other 4xx.
    Permanent(String),
}

pub trait ActivityTarget {
    fn id(&self) -> &'static str;
    async fn upload(&self, activity: Activity<'_>) -> Result<UploadOutcome, UploadError>;
}

crates/sync takes no Tauri dependency and knows nothing about where rides live, for the same reason crates/fit knows neither: it can then be tested against a local mock HTTP server with no app running, the way fit is tested by encoding with our writer and decoding with somebody else's parser. Duplicate is an outcome rather than an error precisely because FR-10.19 turns on it, and a variant is harder to mishandle than a string match at a call site.

Dependency: reqwest with rustls-tls, not native-tls. OpenSSL cross-compiled against the Android NDK is a build problem this project does not need, and pure-Rust crypto is the choice already made for the Click session (§4).

5.10.5 The token

devices.json and settings.json are plaintext, which is right for a MAC address and a rider's mass. An API token is not in that class: it grants write access to somebody's training history for as long as they leave it valid.

ID Requirement Priority
FR-10.25 The token is not stored in settings.json beside the rider's mass Must
FR-10.26 Desktop: the OS credential store (keyring — Secret Service, Keychain, Credential Manager). A store that is missing or locked degrades to no token configured and asks the rider to paste it again; it never silently falls back to a plaintext file Must
FR-10.27 Android: app-private storage, which the OS already isolates per app — EncryptedSharedPreferences if it can be reached without a Java dependency costing more than it saves Should
FR-10.28 The UI never redisplays a stored token. It shows configured / not configured, and offers replace and remove Must

And be honest about the ceiling: on a rooted phone, or a desktop with no keyring daemon running, none of this hides the token from someone holding the device. What it buys is that the token is not sitting in a world-readable JSON file beside the rides, and is not in the settings file a rider pastes into a bug report.


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 — every function that is part of riding works with no internet connection: discovery, connection, control, profiles, physics, recording and the FIT file. The app contacts no service unless the rider has configured one (FR-10.11), and it never contacts Zwift — only the rider's separate daily unlock needs the internet. An installation with no upload token configured originates no network traffic at all
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
NFR-15 Nothing on the network is on the ride path — no ride may be blocked, delayed or degraded by a network operation. Sharing and uploading run behind the ride, never in front of it; the ride loop awaits neither, and a service that is down, slow or wrong costs a rider an upload and never a session
NFR-16 Credentials are not settings — an API token is never stored in settings.json, never logged at any level, and never redisplayed by the UI once stored (FR-10.22, FR-10.2510.28). NFR-8's "log everything" stops at the credential

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

Everything above is the ride. §5.10 hangs off the right of it and touches nothing in it:

   Recorder ──► rides/<stamp>.fit ──┬──► the FIT is the ride artifact; nothing below alters it
                                    │
                                    ├──► FR-9.14  save dialog   (desktop + Android, today)
                                    ├──► FR-10.1  share sheet   (Android — JNI → Kotlin → FileProvider)
                                    └──► FR-10.9  upload queue  ──► crates/sync ──► Runalyze
                                             uploads.json                      (HTTPS, opt-in)

The arrows only ever point away from the file. Nothing on the right re-enters the ride loop, and the queue is the only thing there that owns state of its own.


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.

Phase 5 — Sharing and upload

FR-10, plus the NFR-6 / NFR-15 / NFR-16 amendments. Two independent pieces: the share ships first and is blocked on nothing in the upload.

Step Work
1 Share (FR-10.110.8). file_paths.xml becomes a tracked source under src-tauri/android/ with a <files-path> for rides/, copied by sync-android-sources.sh and checked in CI like the manifest (NFR-13). One Kotlin method on MainActivity, one JNI entry in android.rs, one command, one button on the summary. Settles OQ-11 on a real phone against Garmin Connect and Strava — which is the reason it goes first
2 Token storage (FR-10.2510.28), before any code exists that has a token to lose
3 crates/sync and the Runalyze impl, against a local mock HTTP server. Then one real upload and one deliberate re-upload by hand, both bodies pinned as fixtures — OQ-12, without which FR-10.19 is a guess
4 The queue (FR-10.1210.21): uploads.json, the drain task, the prune predicate, the UI

Milestone: a ride reaches Garmin Connect from the phone with no cable, and reaches Runalyze without the rider touching anything.


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
RISK-10 The FIT share MIME type is not what the receiving apps filter on The chooser comes up empty, with no error anywhere. A feature that looks broken and logs nothing OQ-11: settle it on a real phone against Garmin Connect, Strava and a file manager before the UI is written. application/vnd.ant.fit is the registered type; application/octet-stream is what many Android apps actually match, and is the fallback
RISK-11 Strava is the service riders will ask for next, and its token exchange needs a client_secret inside an open-source native binary — it has no PKCE — against a rate limit shared by every install rather than held per rider An obvious feature request carries policy risk and a ceiling that arrives without warning Deferred deliberately, not forgotten. Runalyze first proves the trait, the queue and the error classification; when Strava is done, the likely shape is the rider registering their own API application, which moves both the secret and the rate limit onto them. Until then the share sheet reaches Strava's own app and needs none of it
RISK-12 Runalyze's success and duplicate response bodies are undocumented — the reference client discards the body apart from one substring check, and the published docs are behind a login FR-10.18/10.19's idempotency would rest on a guess, and a retried upload could silently double a ride Capture both by hand and pin them as fixtures before implementing (OQ-12). Until then, treat a 2xx not containing No valid token as accepted and do not claim duplicate detection at all
RISK-13 file_paths.xml is generated by the dialog plugin and regenerated by tauri android init, and its roots do not cover app-private storage Share throws Failed to find configured root in a build that is otherwise perfectly healthy Exactly the failure NFR-13 exists for. The file moves under src-tauri/android/, the sync script copies it, and CI checks it as it already checks the manifest

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?
OQ-11 Which MIME type do Garmin Connect's and Strava's Android apps actually accept a .fit on — application/vnd.ant.fit, application/octet-stream, or both? Answered by trying it on a phone, not by reading (RISK-10).
OQ-12 What do Runalyze's upload responses look like on success, and on a re-upload of a file already accepted? Both need capturing before FR-10.19 can be written (RISK-12).
OQ-13 Is auto-upload a standing preference or a per-ride choice — and if standing, does a ride abandoned after ninety seconds get uploaded too? (Suggest: standing, with a minimum-duration floor, so a two-minute test of the trainer does not land in the training log.)

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
FileProvider Android component that hands another app a content:// URI onto a file in this app's private storage, without making the file world-readable (FR-10.2)
Personal API token Runalyze's credential: a string the rider generates in their own account settings and pastes in. Not OAuth — no authorisation flow, no redirect, no client secret (FR-10.10)