# 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.4–3.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 100–600 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 10–30 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.4–3.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 `0x0B` — **one 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 0–3, face buttons on 4–7, paddles at 8 and 12. **Bits 9–11 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 09` → `RideOn 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](https://github.com/cagnulein/qdomyos-zwift/issues/3678) > reports Click shifting on a D100 dying after 30–60 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 0–31 = AES key, bytes 32–35 = 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 10–30 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): ```yaml 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.1–10.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.9–10.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}.fileprovider` — `tauri-plugin-dialog` put it there — but its generated `res/xml/file_paths.xml` grants only `` and ``. The rides directory is neither: `app_data_dir()` on Android is internal, app-private storage. `file_paths.xml` therefore needs a `` 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: ```text ACTION_SEND type = 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`): ```text POST https://runalyze.com/api/v1/activities/uploads 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_ride` → `POST` 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. ```text 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 ```text 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 ``` ```rust pub struct Activity<'a> { pub path: &'a Path, pub filename: &'a str } pub enum UploadOutcome { Accepted { remote_id: Option }, 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; } ``` `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 (1–4 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 | **Portability** — `core` 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 source** — `src-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.25–10.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/.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 0–3, 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 4–7 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.1–9.3, 9.5–9.8, 9.10–9.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.1–3.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.1–10.8).** `file_paths.xml` becomes a tracked source under `src-tauri/android/` with a `` 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.25–10.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.12–10.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.4–3.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) |